AwardPointsOnOrderPlaced: Punkte bei Bestellabschluss gutschreiben
AwardPointsOnOrderPlaced: Punkte bei Bestellabschluss gutschreiben
~9 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Dies ist der Moment, auf den die gesamte Serie seit Kapitel 5 hinarbeitet: ein einziger Observer, AwardPointsOnOrderPlaced, verbindet PointsCalculator (Kapitel 5), CategoryBonusResolver (Kapitel 27), LoyaltyConfig (Kapitel 7), das loyalty_points_multiplier-Produktattribut (Kapitel 19), das loyalty_points_earned-Sales-Attribut (Kapitel 23) und loyalty_points_balance am Kunden (Kapitel 21) tatsächlich zu einem einzigen Ablauf.
Das Event: sales_order_place_after
sales_order_place_after feuert, sobald eine Bestellung erfolgreich platziert wurde - unabhängig davon, ob sie aus dem Storefront-Checkout oder aus "Auftrag anlegen" im Admin stammt, denn beide Wege laufen letztlich durch dieselbe Quote-zu-Order-Umwandlung. Genau deshalb gehört events.xml hier in etc/ (global), nicht in etc/frontend/.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="sales_order_place_after">
<observer name="mironsoft_loyalty_award_points_on_order_placed"
instance="Mironsoft\Loyalty\Observer\AwardPointsOnOrderPlaced"/>
</event>
</config>Achtung: Bewusst nicht sales_order_save_after: dieses Event feuert bei jedem Speichern einer Bestellung - auch bei einer späteren Statusänderung, einem Admin-Kommentar oder einer Rechnungserstellung. Ein Observer darauf würde bei jeder dieser Aktionen erneut Punkte gutschreiben. sales_order_place_after markiert dagegen genau den einmaligen Moment der Bestellaufgabe.
Observer\AwardPointsOnOrderPlaced
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Observer;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Event\Observer as EventObserver;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Mironsoft\Loyalty\Model\Service\CategoryBonusResolver;
use Mironsoft\Loyalty\Model\Service\PointsCalculator;
use Psr\Log\LoggerInterface;
/**
* Credits loyalty points to the customer immediately after an order is placed,
* whether the order originated from storefront checkout or the admin order form.
*/
class AwardPointsOnOrderPlaced implements ObserverInterface
{
/**
* @param PointsCalculator $pointsCalculator Pure per-item points calculation (chapter 5).
* @param CategoryBonusResolver $categoryBonusResolver Resolves the category bonus for a product (chapter 27).
* @param LoyaltyConfig $loyaltyConfig Typed configuration reader (chapter 7).
* @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists the earn ledger entry.
* @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates a new, unsaved ledger entry.
* @param OrderRepositoryInterface $orderRepository Persists the order with its computed loyalty_points_earned values.
* @param CustomerRepositoryInterface $customerRepository Loads and saves the customer's points balance.
* @param DateTime $dateTime Magento's date helper, used to compute the ledger expiry date.
* @param LoggerInterface $logger Logs failures without letting them break checkout, see the warning below.
*/
public function __construct(
private readonly PointsCalculator $pointsCalculator,
private readonly CategoryBonusResolver $categoryBonusResolver,
private readonly LoyaltyConfig $loyaltyConfig,
private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
private readonly OrderRepositoryInterface $orderRepository,
private readonly CustomerRepositoryInterface $customerRepository,
private readonly DateTime $dateTime,
private readonly LoggerInterface $logger
) {
}
/**
* Entry point required by ObserverInterface. Delegates to awardPoints() and
* swallows every exception - see the warning below this listing for why an
* observer on a checkout-critical event must never let an error bubble up.
*
* @param EventObserver $observer Carries the placed order as event data.
* @return void
*/
public function execute(EventObserver $observer): void
{
/** @var Order $order */
$order = $observer->getEvent()->getData('order');
try {
$this->awardPoints($order);
} catch (\Throwable $exception) {
$this->logger->error(
sprintf(
'Mironsoft_Loyalty: failed to award points for order #%s: %s',
(string) $order->getIncrementId(),
$exception->getMessage()
),
['exception' => $exception]
);
}
}
/**
* Calculates and persists earned points for every order item, updates the
* customer's balance, and writes one "earn" ledger entry for the whole order.
*
* @param Order $order The just-placed order.
* @return void
*/
private function awardPoints(Order $order): void
{
if ($order->getCustomerIsGuest() || (int) $order->getCustomerId() === 0) {
return; // business rule: guests do not earn points
}
if ((int) $order->getData('loyalty_points_earned') > 0) {
return; // idempotency guard: already awarded, e.g. on a repeated dispatch
}
$websiteId = (int) $order->getStore()->getWebsiteId();
if (!$this->loyaltyConfig->isEnabled($websiteId)) {
return;
}
$pointsPerEuro = $this->loyaltyConfig->getPointsPerEuro($websiteId);
$totalPointsEarned = 0;
foreach ($order->getAllVisibleItems() as $item) {
$product = $item->getProduct();
$multiplier = $product !== null
? (float) $product->getData('loyalty_points_multiplier')
: 1.0;
$categoryBonus = $product !== null
? $this->categoryBonusResolver->resolveForProduct($product)
: 0.0;
$itemPoints = $this->pointsCalculator->calculatePoints(
(float) $item->getRowTotal(),
$pointsPerEuro,
$multiplier > 0.0 ? $multiplier : 1.0,
$categoryBonus
);
$item->setData('loyalty_points_earned', $itemPoints);
$totalPointsEarned += $itemPoints;
}
if ($totalPointsEarned === 0) {
return;
}
$order->setData('loyalty_points_earned', $totalPointsEarned);
$this->orderRepository->save($order);
$this->creditCustomer(
(int) $order->getCustomerId(),
$totalPointsEarned,
(int) $order->getEntityId()
);
}
/**
* Credits points to the customer's balance and appends the matching ledger entry.
*
* @param int $customerId Customer entity ID.
* @param int $points Points earned by this order, always positive here.
* @param int $orderId Order entity ID, stored on the ledger entry for traceability.
* @return void
*/
private function creditCustomer(int $customerId, int $points, int $orderId): void
{
$customer = $this->customerRepository->getById($customerId);
$currentAttribute = $customer->getCustomAttribute('loyalty_points_balance');
$currentBalance = $currentAttribute !== null ? (int) $currentAttribute->getValue() : 0;
$newBalance = $currentBalance + $points;
$ledgerEntry = $this->pointsLedgerFactory->create();
$ledgerEntry->setCustomerId($customerId);
$ledgerEntry->setOrderId($orderId);
$ledgerEntry->setType(PointsLedgerInterface::TYPE_EARN);
$ledgerEntry->setPoints($points);
$ledgerEntry->setBalanceAfter($newBalance);
$ledgerEntry->setExpiresAt($this->resolveExpiresAt());
$this->pointsLedgerRepository->save($ledgerEntry);
$customer->setCustomAttribute('loyalty_points_balance', $newBalance);
// Saving through the repository re-triggers LoyaltyTierBackend::beforeSave()
// (chapter 26), which recalculates loyalty_tier from the new balance - no
// tier logic is duplicated here.
$this->customerRepository->save($customer);
}
/**
* Computes the expiry timestamp for a fresh earn entry from the configured
* expiry period, or null if points never expire.
*
* @return string|null
*/
private function resolveExpiresAt(): ?string
{
$months = $this->loyaltyConfig->getPointsExpiryMonths();
if ($months <= 0) {
return null;
}
return $this->dateTime->date('Y-m-d H:i:s', strtotime(sprintf('+%d months', $months)));
}
}Warum getCustomAttribute() statt getData()?
CustomerRepositoryInterface::getById() liefert ein \Magento\Customer\Api\Data\CustomerInterface-Datenobjekt zurück, keine \Magento\Customer\Model\Customer-Instanz. Eigene EAV-Attribute sind dort nicht über getData()/setData() erreichbar, sondern ausschließlich über getCustomAttribute(string $attributeCode) (liefert AttributeValueInterface|null) und setCustomAttribute(string $attributeCode, $value) - der service-contract-konforme Weg, den CLAUDE.md mit "Service Contracts und Repositories bevorzugen" verlangt. Beim anschließenden save() kopiert das Repository intern alle Custom Attributes zurück auf das zugrunde liegende EAV-Model, wodurch LoyaltyTierBackend::beforeSave() (Kapitel 26) den neuen Wert von loyalty_points_balance ganz normal zu Gesicht bekommt.
Echte Verzahnung: eine Übersicht
- Kapitel 5
PointsCalculator::calculatePoints()- berechnet die Punkte je Bestellposition. - Kapitel 27
CategoryBonusResolver::resolveForProduct()- liefert den Kategorie-Bonus dafür. - Kapitel 19
loyalty_points_multiplier- direkt vom Produkt gelesen. - Kapitel 23
loyalty_points_earned- hier zum ersten Mal tatsächlich beschrieben, auf Order UND Order Item. - Kapitel 21/26
loyalty_points_balanceundloyalty_tier- Balance wird hier gesetzt, Tier berechnet sich automatisch über das Backend Model. - Kapitel 6
PointsLedgerRepositoryInterface::save()- schreibt den Audit-Trail-Eintrag.
Achtung: events.xml-Observer laufen synchron (Kapitel 29) - eine unbehandelte Exception in diesem Observer würde den kompletten Checkout-Request des Kunden abbrechen, obwohl die Bestellung selbst bereits erfolgreich platziert wurde. Genau deshalb kapselt execute() die eigentliche Logik in try/catch (\Throwable) und protokolliert Fehler nur - ein Kunde soll niemals eine Fehlerseite sehen, nur weil die Punktegutschrift scheitert. Kapitel 34 zeigt die vergleichbare, aber technisch andere Absicherung für den Cronjob.
Tipp: Die Prüfung (int) $order->getData('loyalty_points_earned') > 0 am Anfang von awardPoints() ist eine einfache Idempotenz-Sperre: sollte sales_order_place_after aus irgendeinem Grund ein zweites Mal für dieselbe Bestellung feuern (selten, aber bei bestimmten Zahlungsart-Redirect-Abläufen nicht ausgeschlossen), verhindert sie eine doppelte Punktegutschrift - dasselbe Grundproblem, dem Kapitel 31 bei Gutschriften mit einer anderen Technik begegnet.
Kapitel 31 dreht die Richtung um: Was passiert mit bereits vergebenen Punkten, wenn eine Bestellung teilweise oder vollständig erstattet wird?