Der PointsCalculator-Service: zentrale Geschäftslogik als Service Contract statt Helper
Der PointsCalculator-Service: zentrale Geschäftslogik als Service Contract statt Helper
~7 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Wie viele Punkte bekommt ein Kunde für eine Bestellposition? Welche Treue-Stufe hat er bei einem gegebenen Gesamtpunktestand? Diese beiden Fragen beantwortet in dieser Serie eine einzige Klasse: Mironsoft\Loyalty\Model\Service\PointsCalculator. Sie ist die wichtigste Klasse des gesamten Moduls - praktisch jeder spätere Block referenziert sie, und Block 11 macht sie zum Hauptbeispiel für Unit Tests (Kapitel 91-92).
Warum kein Helper?
Ein klassischer Magento-Helper erbt von \Magento\Framework\App\Helper\AbstractHelper und wird meist über ObjectManager::helper() beziehungsweise implizite Auto-Injection genutzt. Das Problem: Die Basisklasse zieht automatisch einen kompletten Context mit HTTP-Request, Event-Manager und weiterem hinein - selbst wenn die eigentliche Logik, wie hier, komplett zustandslos und ohne HTTP-Bezug ist. PointsCalculator ist deshalb eine gewöhnliche PHP-Klasse ohne Zwangsvererbung, die nur die Abhängigkeiten deklariert, die sie wirklich braucht. Kapitel 44 (Block 5) zeigt im Gegenzug einen Fall, in dem eine klassische Helper-Klasse trotzdem berechtigt ist.
Zwei reine Geschäftsregeln
calculatePoints() berechnet die Punkte für eine einzelne Bestellposition aus Zeilensumme, dem konfigurierten Punkte-pro-Euro-Satz (Kapitel 7), dem Produkt-Multiplikator (Kapitel 19, hier als Parameter durchgereicht) und einem optionalen Kategorie-Bonus (Kapitel 20). determineTier() bestimmt anhand der Gesamtpunktzahl und der konfigurierten Schwellenwerte (Kapitel 7) die aktuelle Treue-Stufe. Beide Methoden sind reine Funktionen im mathematischen Sinn: gleiche Eingabe, immer gleiche Ausgabe, keine Seiteneffekte, kein Datenbankzugriff.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Service;
use Magento\Framework\Serialize\Serializer\Json;
/**
* Pure business logic for calculating earned points and loyalty tiers.
* Deliberately free of database or HTTP dependencies so it stays trivially
* unit-testable (see block 11, chapters 91-92).
*/
class PointsCalculator
{
/**
* @var string
*/
public const TIER_BRONZE = 'bronze';
/**
* @var string
*/
public const TIER_SILVER = 'silver';
/**
* @var string
*/
public const TIER_GOLD = 'gold';
/**
* @param Json $serializer Decodes the JSON-encoded tier thresholds configuration value.
*/
public function __construct(
private readonly Json $serializer,
) {
}
/**
* Calculates how many points an order line item earns.
*
* @param float $lineTotal Line total excl. tax, in store currency.
* @param float $pointsPerEuro Points-per-euro conversion rate from configuration.
* @param float $productMultiplier Product-level multiplier (loyalty_points_multiplier attribute), 1.0 = no change.
* @param float $categoryBonus Additional category-level bonus rate, 0.0 = no bonus.
* @return int
*/
public function calculatePoints(
float $lineTotal,
float $pointsPerEuro,
float $productMultiplier = 1.0,
float $categoryBonus = 0.0
): int {
$rawPoints = $lineTotal * $pointsPerEuro * $productMultiplier;
$rawPoints += $lineTotal * $categoryBonus;
return (int) floor(max(0.0, $rawPoints));
}
/**
* Determines the loyalty tier for a given total of earned points.
*
* @param int $totalPointsEarned Sum of all "earn" ledger entries for the customer.
* @param string $tierThresholdsJson JSON-encoded thresholds, e.g. {"silver":500,"gold":2000}.
* @return string
*/
public function determineTier(int $totalPointsEarned, string $tierThresholdsJson): string
{
/** @var array{silver?: int, gold?: int} $thresholds */
$thresholds = $this->serializer->unserialize($tierThresholdsJson);
if ($totalPointsEarned >= ($thresholds['gold'] ?? PHP_INT_MAX)) {
return self::TIER_GOLD;
}
if ($totalPointsEarned >= ($thresholds['silver'] ?? PHP_INT_MAX)) {
return self::TIER_SILVER;
}
return self::TIER_BRONZE;
}
}Warum Json-Serializer statt eigenem Parsing?
\Magento\Framework\Serialize\Serializer\Json ist die von Magento selbst empfohlene Stelle für JSON-Encoding/Decoding - sie kapselt Fehlerbehandlung, die bei direktem json_decode() leicht vergessen wird (zum Beispiel eine fehlerhafte JSON-Zeichenkette in der Konfiguration, Kapitel 7), und lässt sich in Tests trivial durch ein Double ersetzen.
Tipp: calculatePoints() rundet bewusst mit floor() ab statt kaufmännisch zu runden - ein Kunde soll nie durch einen Rundungsfehler mehr Punkte bekommen, als ihm rechnerisch zusteht. max(0.0, ...) verhindert außerdem negative Punktzahlen, falls ein negativer Kategorie-Bonus konfiguriert wird.
Wo der Service registriert wird
PointsCalculator hat keine Interface-Präferenz in di.xml nötig - er ist keine Service-Contract-Implementierung, sondern wird direkt über seinen Klassennamen injiziert, überall dort, wo Punkte berechnet werden müssen (unter anderem der Observer aus Kapitel 30 und der Konsolenbefehl aus Kapitel 9).
Kapitel 6 nutzt PointsLedger und die Collection aus Kapitel 4, um diese berechneten Punkte tatsächlich zu persistieren - über ein sauberes Repository statt direktem ResourceModel-Zugriff aus Controllern oder Observern.