Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

A Plugin on the Checkout Totals Collector: Applying a Points Discount

A Plugin on the Checkout Totals Collector: Applying a Points Discount

~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

Chapter 37 promised it: this chapter implements this series' first plugin that actually changes a number the customer sees at checkout. Business rule: a customer can redeem part of their loyalty_points_balance (chapter 21) as a discount on the order total. How the customer expresses that wish comes later (the redeem controller in chapter 50 and the "redeem points" payment method in chapter 63) - this chapter deliberately assumes the desired point count already sits on the quote as loyalty_points_to_redeem, and focuses on the plugin technique itself.

Target class: Magento\Quote\Model\Quote\TotalsCollector

TotalsCollector::collectAddressTotals(Quote $quote, Address $address): Address is the central place where Magento assembles subtotal, shipping, tax, and discounts into an Address object with grand_total/base_grand_total whenever totals get recalculated - cart view, every checkout step, order placement. Anyone who actually wants to change a number at checkout has to hook in here.

Why after, not around?

This isn't about whether the total gets computed, or with which arguments - both stay untouched. It's purely about reducing the result by the discount amount afterward. According to chapter 38, that's the textbook case for after, not around - no $proceed() handling, no extra function-call overhead.

The plugin code

app/code/Mironsoft/Loyalty/Plugin/Checkout/ApplyPointsRedemptionToTotalsPlugin.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Plugin\Checkout;

use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address;
use Magento\Quote\Model\Quote\TotalsCollector;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Psr\Log\LoggerInterface;

/**
 * Reduces the quote address grand total by the euro value of any loyalty
 * points the customer has chosen to redeem for this order.
 */
class ApplyPointsRedemptionToTotalsPlugin
{
    /**
     * @param CustomerRepositoryInterface $customerRepository Loads the customer's current points balance.
     * @param LoyaltyConfig $loyaltyConfig Provides the points-per-euro conversion rate.
     * @param LoggerInterface $logger Logs unexpected lookup failures without breaking totals collection.
     */
    public function __construct(
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly LoyaltyConfig $loyaltyConfig,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Applies the points-redemption discount to the already-collected totals.
     *
     * @param TotalsCollector $subject The intercepted totals collector instance.
     * @param Address $result Address with subtotal/shipping/tax/grand_total already computed.
     * @param Quote $quote The quote being totalled.
     * @param Address $address The quote address being totalled.
     * @return Address
     */
    public function afterCollectAddressTotals(
        TotalsCollector $subject,
        Address $result,
        Quote $quote,
        Address $address
    ): Address {
        $customerId = (int) $quote->getCustomerId();
        $pointsToRedeem = (int) $quote->getData('loyalty_points_to_redeem');

        if ($customerId <= 0 || $pointsToRedeem <= 0) {
            return $result;
        }

        try {
            $customer = $this->customerRepository->getById($customerId);
        } catch (LocalizedException $exception) {
            $this->logger->warning(
                'Loyalty: could not load customer for points redemption.',
                ['customer_id' => $customerId, 'exception' => $exception]
            );

            return $result;
        }

        $balanceAttribute = $customer->getCustomAttribute('loyalty_points_balance');
        $balance = $balanceAttribute !== null ? (int) $balanceAttribute->getValue() : 0;
        $redeemedPoints = min($pointsToRedeem, $balance);

        $pointsPerEuro = $this->loyaltyConfig->getPointsPerEuro();

        if ($redeemedPoints <= 0 || $pointsPerEuro <= 0.0) {
            return $result;
        }

        $discount = round($redeemedPoints / $pointsPerEuro, 2);
        $discount = min($discount, (float) $result->getGrandTotal());

        $result->setGrandTotal((float) $result->getGrandTotal() - $discount);
        $result->setBaseGrandTotal((float) $result->getBaseGrandTotal() - $discount);
        $result->setData('loyalty_points_redeemed', $redeemedPoints);
        $result->setData('loyalty_points_discount', $discount);

        return $result;
    }
}

The conversion deliberately reuses the same points_per_euro rate (chapter 7) used for crediting points on purchase - a deliberate simplification to avoid introducing a fifth configuration path. A real project would often make a separate, less generous redemption rate configurable here, to keep an economic buffer between earning and redeeming.

di.xml registration

app/code/Mironsoft/Loyalty/etc/di.xml
<type name="Magento\Quote\Model\Quote\TotalsCollector">
    <plugin name="mironsoft_loyalty_apply_points_redemption_to_totals"
            type="Mironsoft\Loyalty\Plugin\Checkout\ApplyPointsRedemptionToTotalsPlugin"
            sortOrder="100"/>
</type>

Tipp: This plugin lives in the module-wide etc/di.xml, not in an etc/frontend/ variant: TotalsCollector runs identically for the storefront checkout AND for manual order creation in the admin panel - a points discount an admin agent redeems for a customer should behave exactly the same way.

Achtung: This plugin deliberately does not book a TYPE_REDEEM entry in the points ledger (chapter 3). collectAddressTotals() can run multiple times per request, and again every time the cart or checkout page reloads - booking here would immortalize the same event in the ledger over and over. The actual, one-time booking belongs where the order is finally placed (chapter 63, the "redeem points" payment method) - this plugin only ever affects the displayed total.

A production plugin is in place. Chapter 40 clarifies when even a plugin isn't enough anymore - and why this module still reaches for a preference as rarely as possible even then.