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

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/.

app/code/Mironsoft/Loyalty/etc/events.xml
<?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

app/code/Mironsoft/Loyalty/Observer/AwardPointsOnOrderPlaced.php
<?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_balance und loyalty_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?