Skip to content

Work that comes back every week? No human needed. More on AI and automation

Open Source

Make Magento 2 Discounts Readable: One Line per Cart Price Rule

A thick metal bar at the left separating into four flat slats of different widths that run on to the right.

On a shop I look after, a note that no customer was meant to read appeared in the cart. The text came from a rule’s description field, which the admin presents as a place for the team’s own notes.

What Magento stores, and what it does not

Magento merges every applied discount into one totals row: “Discount (…)”. The text in brackets is built from the rules’ labels. The chain is in RulesApplier::getRuleLabel(): the rule’s store label first, then, when a coupon applies, the rule description, then the coupon code.

In the admin, description is a notes field. It holds whatever the team wrote down for itself: which campaign a rule belongs to, which ticket it came from. That is the text that reached the cart.

A template fix cannot produce one line per rule, because the amounts are not stored. On the order, Magento keeps three fields:

  • coupon_code, a single code
  • discount_description, a text field
  • applied_rule_ids, a list of ids

Those three are the whole record. sales_order in Magento/Sales/etc/db_schema.xml carries coupon_code and discount_description as varchar(255), applied_rule_ids as varchar(128), and no column for an amount per rule. Checked against Magento 2.4.7.

No per-rule amounts. Once the order exists, the split of the discount across the rules cannot be reconstructed. Anything that wants one line per rule has to store the amounts at the moment the order is created.

I have also seen the same text in the shipping row, “Shipping & Handling (…)”, on an order whose only rule was free shipping. A free-shipping rule runs through the same description code, in Validator::processShippingAmount(). I could not find the core line that puts the text into that row, so take the shipping row as something I saw in the lab and the discount row as the path the source shows.

The same mechanism in the lab, with core's merged row rendered inside the new email template. The rule description is in the customer's inbox. Test data from a clean Mage-OS.

The email itself is not Magento’s. That layout comes from a second module, described in the sister post, and neither module needs the other.

The benchmark was Shopify

Shopify does this better, and that is what started the module. Its checkout lists each applied discount as its own line in the order summary, including when several discounts combine (discount combinations changelog). The separate lines are my own reading of a Shopify checkout rather than a documented promise. I looked for a page in Shopify’s own documentation that states it, and did not find one.

Shopify has its own weakness here: the line carries the code the customer typed, and a merchant cannot substitute a friendly name (Shopify Community). What it shows for an automatic discount, which has no code to show, I have not been able to establish from a source worth citing.

So the target was both: one line per rule, and a label that comes from a field written for customers, including for a rule the customer never typed anything to trigger.

The label rule

KindLabelAmount column
couponthe coupon code-amount
automaticthe rule’s store label, else a translated “Discount”-amount
free_shippingthe code or the store label, else “Free shipping”-shipping discount, or “Free”
giftthe coupon code of the promotion”Gift” when it carries no money, otherwise the amount
gift_card”Gift card ••••1234”, masked-amount

The rule description and the rule name are never used. Labels and amounts are snapshotted when the order is placed, so a rule that is relabelled or deleted later does not change existing orders.

The same order with one line per rule. The automatic rule renders its store label, the coupon renders the code, and each names its own amount.
Three kinds in one cart: an automatic rule, a coupon and a gift card. The card is masked to its last four digits, which is enough for a customer to tell which card they used.

How the amounts survive checkout

Storage on the quote address, at collect time. After an address’s totals are collected, the per-rule lines are resolved and stored on the quote address (quote_address.pp_discount_lines). Resolution is one generic rule resolver that reads core’s own per-rule discount breakdown of the address, so it covers every simple_action including by_percent, by_fixed, cart_fixed and buy_x_get_y, plus a free shipping resolver. Both sit in a composite, and that composite is the extension point: an adapter adds a named resolver to it through di.xml. A failed collect clears the stored lines.

A totals segment extension attribute. A plugin on CartTotalRepositoryInterface adds the lines to the discount total segment as extension_attributes.discount_lines and replaces the segment title, which carries the rule description in core. A quote with only zero-value lines, a gift or free shipping, gets a zero discount segment so renderers still receive the lines. Stored lines that do not add up to the segment value are not exposed at all, and the title stays the plain “Discount”.

Persistence at order placement. On sales_model_service_quote_submit_success the lines are written to pp_discount_line: order-level lines plus item-level per-rule amounts, which are what make the credit memo split possible. A discounted order with no attributable lines gets one generic automatic “Discount” line for the full amount, so an order never falls back to the core label.

The contract, for anyone rendering the rows

Each line is its own totals row in the standard Magento totals blocks, with the row code pp_discount_line_<n>, replacing the core discount row. The row carries the data key discount_line:

discount_line = {
  kind:        "coupon" | "automatic" | "gift" | "free_shipping" | "gift_card",
  rule_id:     int|null,      // null for gift_card
  code:        string|null,   // coupon or gift card code, masked for gift_card
  label:       string,        // resolved by the label rule, safe to show
  amount:      float,         // order currency, positive number = discount
  base_amount: float
}

Detect a discount line by that data key, not by the row code. The Mageplaza adapter keeps Mageplaza’s own row codes and still carries discount_line with kind gift_card. Row label and value stay populated as core expects, so a template that ignores the data key still prints a correct plain row.

One base, small renderers

The base package knows no frontend. It owns the data, the storage, the label rule and every sales document. Storefront rendering lives in separate packages, so a shop installs only what it runs.

PackageWhat it adds
magento2-module-discount-linesData, storage, label rule, documents, configuration
-hyva-checkoutHyvä Checkout price summary
-hyva-themeHyvä default theme cart page
-lumaLuma cart summary and checkout sidebar
-amasty-promoAmasty Free Gift rules as gift lines
-mageplaza-giftcardMageplaza gift cards, masked

All six were released as 1.0.0 on 2026-11-01, MIT licensed, on GitHub and Packagist, so the Hyvä and Luma renderers both exist today.

The two Hyvä packages are not alternatives to each other. -hyva-theme renders the cart page of the Hyvä default theme and -hyva-checkout renders the price summary in Hyvä Checkout, so a shop running both needs both.

Hyvä Checkout reads the same data through the totals segment, so the checkout summary and the cart agree without either one knowing about the other.
Luma, the same lab order. The renderer is a separate package, so a Luma shop installs no Hyvä code and the other way round.

Loki Checkout is not built. Because the base package is frontend-agnostic, any further checkout can get a renderer, but today none exists.

Why Amasty and Mageplaza, and how to add your own

Those two adapters exist because those two extensions are in the installation I work with, not because I surveyed the market. Mageplaza happens to be the gift card extension running there.

For any other provider, write the adapter yourself. It is one class with one method:

interface DiscountLineResolverInterface
{
    /**
     * Resolve discount lines for a collected quote address.
     *
     * @return DiscountLineInterface[]
     */
    public function resolve(Address $address, Quote $quote): array;
}

Then hand it to the composite under a name of your own, 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>

An entry that does not implement the interface is skipped and logged rather than thrown, so a broken adapter degrades to the core row instead of taking the cart down.

Four variants, chosen per store view

Under Stores > Configuration > Sales > Discount Lines: A puts the coupon chip below the label, B makes the chip the label and is the default, C groups the lines with a “You save” sum, and D uses an icon and a monospace code with no chip.

Variant C groups the same two lines and adds their sum. B and C differ in presentation only, not in the data behind them.

Sales documents always use plain text, for example “Coupon CODE -2.25”, because Outlook and PDF renderers cannot draw a chip safely.

What the lines add up to

The lines always add up to the document’s discount total, to the cent.

Rounding differences of up to one cent per line are absorbed by the largest line. A larger remainder that belongs to no rule becomes a generic “Discount” line rather than being assigned to one, because a wrong attribution is harder to spot than a vague one. An invoice scales the stored lines to the invoice discount; a credit memo splits each rule in proportion to the refunded quantities.

Limits

  • Orders placed before installation keep the merged core row. The per-rule amounts cannot be rebuilt, because Magento never stored them.
  • Multishipping is not supported in 1.0.
  • Across several invoices of one order, per-rule amounts can drift by one cent per rule. Every document’s discount total is exact.
  • With Amasty, a gift line gets too much of the amount when an automatic rule also discounts the gift item. The sum stays correct. The fix is the Amasty condition “is promo item = No” on automatic rules.
  • Existing carts show the core row until the next totals collect.

Install

composer require pixelperfectat/magento2-module-discount-lines
composer require pixelperfectat/magento2-module-discount-lines-hyva-checkout
bin/magento setup:upgrade

The second line is a renderer. Install one for every frontend you run, not one in total: -hyva-checkout for Hyvä Checkout, -hyva-theme for the cart page of the Hyvä default theme, -luma for Luma. A shop that runs the Hyvä theme and Hyvä Checkout installs both. Add -amasty-promo or -mageplaza-giftcard if you run those extensions.

Quality, in numbers

Built and tested in a clean Mage-OS 3.1.0 lab on PHP 8.5, with demo data only. Every package runs composer check in CI: composer validate, PHP lint, PHP_CodeSniffer, PHPStan and PHPUnit. Four of the six add Infection mutation testing, which gates the build below an MSI of 85 or a covered-code MSI of 90.

Mutation tests are deliberately broken copies of the code, and the score is the share the test suite catches. Every figure below was read out of the public CI log of the run linked beside it, not copied from a release note.

PackageMSI / covered-code MSIRun
magento2-module-discount-lines93 / 9342242224997, 2026-11-06
-amasty-promo100 / 10042242248810, 2026-11-06
-mageplaza-giftcard99 / 9942242346501, 2026-11-06
-luma93 / 9342242324118, 2026-11-06
-hyva-themeno Infection stepruns validate, lint, cs, stan, test
-hyva-checkoutno Infection stepruns validate, lint, cs, stan, test

The two Hyvä renderers have no Infection step, so they carry no score. I do not count a missing score as a passing one.

Mutation testing found a real bug before release: an all-digit Mageplaza gift card code crashed the masking.

Please try to break it

These modules are new, and a lab is a narrow slice of what Magento shops throw at a cart: rule combinations, currencies, tax setups, other extensions that also touch totals.

Install them in a test environment and stack things. Several coupons at once, free shipping with a gift, a partial refund across two invoices, a currency switch between order and base. If the numbers disagree, or a line looks wrong, that is worth an issue on GitHub, including when it is only ugly rather than wrong. I read every one.

The emails are a separate question

The lines appear in the order confirmation, but the email itself is still Magento’s. A second module replaces those templates, and this post describes it. Neither module requires the other.

Questions

Why does Magento show only one discount row?

Magento merges every applied discount into the totals row "Discount (...)". On the order it keeps only `coupon_code`, `discount_description` and `applied_rule_ids`, so no per-rule amounts. After checkout the split cannot be reconstructed, which is why emails, invoices and credit memos cannot show it.

Can a cart rule description reach the customer?

Yes. `RulesApplier::getRuleLabel()` in Magento 2.4.7 takes the rule's store label first, and when a coupon applies and no store label is set it takes the rule description, which is a notes field in the admin. An internal note then appears in the discount row of the cart, of the order confirmation and of the invoice. I have also seen the same text in the shipping row, on an order whose only rule was free shipping.

How do the lines relate to Magento's own discount total?

They always sum to it, to the cent. Rounding differences of up to one cent per line are absorbed by the largest line. A larger remainder that belongs to no rule becomes a generic "Discount" line rather than being assigned to one. Lines that exceed the total, or that disagree between the order and the base currency, are dropped, and then the core row appears with the plain label "Discount".

Which frontends are supported?

Hyvä Checkout, the Hyvä default theme and Luma, each as its own package, all released 1.0.0 on 2026-11-01. The base package is frontend-agnostic, so another checkout can get a renderer, but none exists today.

Where Is Your Magento Project Right Now?

Tell me briefly what you are working on, where things stand and what has already been tried. I will be honest about whether I am the right developer for it.