Ein Plugin auf den Checkout-Totals-Collector: Punkte-Rabatt anwenden
Ein Plugin auf den Checkout-Totals-Collector: Punkte-Rabatt anwenden
~9 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Kapitel 37 hat es angekündigt: dieses Kapitel setzt den ersten Plugin dieser Serie um, der tatsächlich in eine Zahl eingreift, die der Kunde im Checkout sieht. Business-Regel: ein Kunde kann einen Teil seines loyalty_points_balance (Kapitel 21) als Rabatt auf die Bestellsumme einlösen. Wie der Kunde diesen Wunsch äußert, entsteht erst später (der Redeem-Controller in Kapitel 50 und die "Punkte einlösen"-Zahlungsart in Kapitel 63) - dieses Kapitel geht bewusst davon aus, dass die gewünschte Punktzahl bereits als loyalty_points_to_redeem auf dem Quote liegt, und konzentriert sich auf die Plugin-Technik selbst.
Zielklasse: Magento\Quote\Model\Quote\TotalsCollector
TotalsCollector::collectAddressTotals(Quote $quote, Address $address): Address ist die zentrale Stelle, an der Magento bei jeder Summen-Neuberechnung - Warenkorb-Ansicht, jeder Checkout-Schritt, Bestellabschluss - Zwischensumme, Versand, Steuer und Rabatte zu einem Address-Objekt mit grand_total/base_grand_total zusammenführt. Wer eine Zahl im Checkout tatsächlich verändern will, muss hier ansetzen.
Warum after und nicht around?
Es geht nicht darum, ob die Summe berechnet wird oder mit welchen Argumenten - beides bleibt unangetastet. Es geht ausschließlich darum, das Ergebnis nachträglich um den Rabattbetrag zu reduzieren. Genau diese Aufgabe ist laut Kapitel 38 der Lehrbuchfall für after, nicht around - kein $proceed()-Handling, kein zusätzlicher Funktionsaufruf-Overhead.
Der 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;
}
}Die Umrechnung nutzt bewusst denselben points_per_euro-Kurs (Kapitel 7) wie die Gutschrift beim Einkauf - eine bewusste Vereinfachung, um keinen fünften Konfigurationspfad einzuführen. In einem echten Projekt würde man hier häufig einen eigenen, günstigeren Einlöse-Kurs konfigurierbar machen, um einen wirtschaftlichen Puffer zwischen Gutschrift und Einlösung zu behalten.
di.xml-Registrierung
<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: Der Plugin steht in der modulweiten etc/di.xml, nicht in einer etc/frontend/-Variante: TotalsCollector läuft identisch bei der Storefront-Kasse UND bei der manuellen Auftragsanlage im Admin-Bereich - ein Punkte-Rabatt, der ein Admin-Mitarbeiter für einen Kunden einlöst, soll genauso wirken.
Achtung: Dieser Plugin bucht bewusst keinen TYPE_REDEEM-Eintrag im Punkte-Ledger (Kapitel 3). collectAddressTotals() läuft potenziell mehrfach pro Request und bei jedem erneuten Aufruf der Warenkorb- oder Checkout-Seite - eine Buchung an dieser Stelle würde denselben Vorgang mehrfach im Ledger verewigen. Die tatsächliche, einmalige Buchung gehört an die Stelle, an der die Bestellung endgültig platziert wird (Kapitel 63, die "Punkte einlösen"-Zahlungsart) - dieses Plugin beeinflusst ausschließlich die angezeigte Summe.
Ein produktiver Plugin steht. Kapitel 40 klärt, wann selbst ein Plugin nicht mehr ausreicht - und warum dieses Modul dann trotzdem so selten wie möglich zur Preference greift.