The PointsCalculator Service: Central Business Logic as a Service Class, Not a Helper
The PointsCalculator Service: Central Business Logic as a Service Class, Not a Helper
~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
How many points does a customer earn for an order line item? What loyalty tier do they hold at a given total point balance? In this series, a single class answers both questions: Mironsoft\Loyalty\Model\Service\PointsCalculator. It's the most important class in the entire module - practically every later block references it, and block 11 makes it the main example for unit testing (chapters 91-92).
Why not a helper?
A classic Magento helper extends \Magento\Framework\App\Helper\AbstractHelper and is typically used via ObjectManager::helper() or implicit auto-injection. The problem: the base class automatically pulls in a full Context with HTTP request, event manager, and more - even when the actual logic, as here, is completely stateless and has nothing to do with HTTP. PointsCalculator is therefore an ordinary PHP class with no forced inheritance, declaring only the dependencies it actually needs. Chapter 44 (block 5) shows, by contrast, a case where a classic helper class is still justified.
Two pure business rules
calculatePoints() calculates the points for a single order line item from the line total, the configured points-per-euro rate (chapter 7), the product multiplier (chapter 19, passed in as a parameter here), and an optional category bonus (chapter 20). determineTier() determines the current loyalty tier from the total point count and the configured thresholds (chapter 7). Both methods are pure functions in the mathematical sense: same input, always the same output, no side effects, no database access.
<?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;
}
}Why the Json serializer instead of manual parsing?
\Magento\Framework\Serialize\Serializer\Json is Magento's own recommended place for JSON encoding/decoding - it encapsulates error handling that is easy to forget with a direct json_decode() call (for example a malformed JSON string in configuration, chapter 7), and it can be trivially swapped for a test double.
Tipp: calculatePoints() deliberately rounds down with floor() instead of rounding to the nearest value - a customer should never receive more points than they're mathematically owed because of a rounding error. max(0.0, ...) also prevents negative point counts in case a negative category bonus gets configured.
Where the service gets registered
PointsCalculator needs no interface preference in di.xml - it isn't a service contract implementation, it just gets injected directly by its class name wherever points need to be calculated (among others, the observer from chapter 30 and the console command from chapter 9).
Chapter 6 uses PointsLedger and the collection from chapter 4 to actually persist these calculated points - through a clean repository instead of direct resource model access from controllers or observers.