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
<?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
<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.