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

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.

app/code/Mironsoft/Loyalty/Model/Service/PointsCalculator.php
<?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.