Cross-Attribute Consistency: When Category and Product Bonus Collide
Cross-Attribute Consistency: When Category and Product Bonus Collide
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
PointsCalculator::calculatePoints() (chapter 5) accepts $categoryBonus as a single float value - but a product in Magento can be assigned to several categories at once, each potentially carrying its own loyalty_bonus_category value (chapter 20). Which value should flow into the calculation when a product sits in both "Sale" (bonus 0.5) and "New Arrivals" (bonus 0.1) at the same time? This chapter closes that gap - deliberately as its own step, separate from the pure calculation in chapter 5.
Why this isn't an edge case
In a real product catalog, multiple assignment is the rule, not the exception - a T-shirt is often assigned to both a navigation category ("Men > T-Shirts") and a marketing category ("Sale", "New In") at the same time. Without a clear rule, calculatePoints() would silently use whichever value happened to load first or last - a bug that only surfaces in production as "inconsistent points credited", never in a unit test with only a single test category (block 11).
A dedicated resolver instead of inline logic
PointsCalculator deliberately stays unaware of "category" as a concept - chapter 5 only hands it an already-resolved number. That's exactly what keeps it so easily unit-testable (block 11). Deciding "which category wins" belongs together as its own business concern and deserves its own, equally purely testable class instead of scattered if logic inside a later observer: Mironsoft\Loyalty\Model\Service\CategoryBonusResolver.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Service;
use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Psr\Log\LoggerInterface;
/**
* Resolves a single, effective category bonus rate for a product that may be
* assigned to several categories, each potentially carrying a different
* loyalty_bonus_category value (chapter 20). The business rule applied here is
* deliberately the most customer-friendly one: the highest bonus among all
* active categories the product belongs to wins.
*/
class CategoryBonusResolver
{
/**
* @param CategoryRepositoryInterface $categoryRepository Loads full category entities, including the EAV attribute.
* @param LoggerInterface $logger Logs categories that can no longer be loaded instead of failing the whole calculation.
*/
public function __construct(
private readonly CategoryRepositoryInterface $categoryRepository,
private readonly LoggerInterface $logger
) {
}
/**
* Returns the highest loyalty_bonus_category value among all active categories
* a product is assigned to, or 0.0 if none apply.
*
* @param ProductInterface $product Product whose assigned categories are inspected.
* @return float
*/
public function resolveForProduct(ProductInterface $product): float
{
$highestBonus = 0.0;
/** @var array<int, string|int> $categoryIds */
$categoryIds = $product->getCategoryIds();
foreach ($categoryIds as $categoryId) {
try {
$category = $this->categoryRepository->get((int) $categoryId);
} catch (NoSuchEntityException $exception) {
$this->logger->warning(sprintf(
'Loyalty: category %d referenced by product %d no longer exists.',
(int) $categoryId,
(int) $product->getId()
));
continue;
}
if (!$category->getIsActive()) {
continue;
}
$bonus = (float) $category->getData('loyalty_bonus_category');
$highestBonus = max($highestBonus, $bonus);
}
return $highestBonus;
}
}Documenting the business rule explicitly
"The highest bonus wins" isn't a mathematical necessity, it's a deliberate choice - equally plausible would be min() (the most conservative variant, discouraging "category farming" through excessive multi-assignment) or summing all bonuses (the most generous variant, but hard for the shop operator to predict). There's no single "correct" answer here - what matters is that the decision lives in one single, named place instead of silently depending on how an array happens to iterate.
Tipp: $product->getCategoryId() (singular) looks like an obvious shortcut for the loop, but it isn't one: this getter only returns the category ID from the current request context (e.g. which category page is currently being displayed), not a permanently stored "primary" category value on the product. Outside of a category page - such as during checkout, exactly where this resolver is actually needed - the value is simply empty. A common misconception that makes getCategoryIds() (plural, as used here) the only reliable source.
Achtung: $this->categoryRepository->get() inside the loop loads each category individually - a classic N+1 problem for an order with many line items and overlapping categories. Deliberately accepted here in favor of clarity; the cache type from chapter 8 would be the obvious next step to avoid repeated loads across many orders.
Anchor categories aren't automatically included
getCategoryIds() only returns the categories a product is directly assigned to - not automatically their parent (anchor) categories. If a parent category "Sale" carries a loyalty_bonus_category value but a product is only assigned to a subcategory, that bonus is ignored by the implementation above - the same anchor category confusion that regularly causes surprises in a catalog context (product listings, navigation). Anyone needing that would additionally have to resolve $category->getParentIds() and repeat the same check for every ancestor category - deliberately out of scope for this chapter.
Using it in the calling code
Here's how CategoryBonusResolver and PointsCalculator look together in calling code, the way the observer from chapter 30 later uses them:
$categoryBonus = $this->categoryBonusResolver->resolveForProduct($product);
$productMultiplier = (float) $product->getData('loyalty_points_multiplier');
$points = $this->pointsCalculator->calculatePoints(
$lineTotal,
$pointsPerEuro,
$productMultiplier,
$categoryBonus
);That wraps up the content of block 3: five attributes, three storage techniques, one backend model for self-consistency, and one resolver service for consistency across attribute boundaries. Chapter 28 sums this up as a checklist, before block 4 introduces the event system in chapter 29, which is where CategoryBonusResolver and LoyaltyTierBackend are actually called for the first time.