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

Rückerstattungen beobachten: Punkte bei Gutschrift zurückbuchen

Rückerstattungen beobachten: Punkte bei Gutschrift zurückbuchen

~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026

Ein Kunde bestellt, erhält Punkte (Kapitel 30) - und schickt die Ware zurück. Ohne Gegenmaßnahme behält er die dafür gutgeschriebenen Punkte trotzdem. Dieses Kapitel beobachtet Gutschriften (Credit Memos) und bucht die entsprechenden Punkte wieder zurück - und stößt dabei auf eine Falle, die in Kapitel 29 nur angekündigt wurde: mehrfach feuernde Events.

save_after oder save_commit_after?

Magento\Sales\Model\Order\Creditmemo feuert wie jede AbstractModel-Entität sales_order_creditmemo_save_after innerhalb der Datenbank-Transaktion, bevor sie committet ist - sowie zusätzlich sales_order_creditmemo_save_commit_after, das erst nach dem erfolgreichen Commit feuert. Für eine Finanzaktion wie das Zurückbuchen von Punkten ist Letzteres die richtige Wahl: schlägt die Transaktion später doch noch fehl (z. B. durch einen Deadlock oder eine spätere Exception im selben Request), wurde die Gutschrift nie wirklich gespeichert - und dann darf auch keine Punkte-Rückbuchung passiert sein.

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>
    <event name="sales_order_creditmemo_save_commit_after">
        <observer name="mironsoft_loyalty_reverse_points_on_creditmemo"
                  instance="Mironsoft\Loyalty\Observer\ReversePointsOnCreditmemoSave"/>
    </event>
</config>

Die Falle: ein Event, mehrere Auslöser

Achtung: sales_order_creditmemo_save_commit_after feuert nicht nur bei der Erstellung einer Gutschrift, sondern bei jedem erneuten Speichern derselben Gutschrift-Entität - zum Beispiel, wenn im Admin nachträglich ein Kommentar zu einer bestehenden Gutschrift hinzugefügt wird ($creditmemo->addComment(...) gefolgt von einem erneuten save()). Ein naiver Observer, der bei jedem Aufruf einfach erneut Punkte zurückbucht, produziert so eine doppelte - oder dreifache - Punktekorrektur, obwohl real nur eine einzige Rückerstattung stattgefunden hat.

Das Ledger-Schema aus Kapitel 3 speichert bewusst keine creditmemo_id - es kennt nur order_id. Ein Zeilen-genauer "habe ich diese eine Gutschrift schon verarbeitet"-Abgleich ist damit nicht möglich, ohne das über elf Kapitel hinweg fixierte Ledger-Schema nachträglich zu ändern - das bleibt bewusst außerhalb dieses Kapitels. Die Lösung: statt zeilenweise zu prüfen, wird bei jedem Aufruf neu berechnet, wie viele Punkte für den bisher erstatteten Gesamtbetrag der Bestellung eigentlich zurückgebucht sein müssten - und nur die Differenz zum bereits Gebuchten tatsächlich verbucht. Ein zweiter, dritter oder zehnter Aufruf für dieselbe Gutschrift bucht dann schlicht null.

Die Collection erweitern: addOrderFilter()

Kapitel 4 kennt bereits addCustomerFilter(). Für die Soll-Ist-Berechnung fehlt eine gleichwertige Filtermethode nach Bestellung - ergänzt die Collection-Klasse, ohne das bestehende PointsLedgerRepositoryInterface (Kapitel 6) zu verändern.

// Ergänzung in app/code/Mironsoft/Loyalty/Model/ResourceModel/PointsLedger/Collection.php

/**
 * Restricts the collection to ledger entries of a single order.
 *
 * @param int $orderId Order entity ID.
 * @return $this
 */
public function addOrderFilter(int $orderId): self
{
    $this->addFieldToFilter('order_id', ['eq' => $orderId]);

    return $this;
}

Observer\ReversePointsOnCreditmemoSave

app/code/Mironsoft/Loyalty/Observer/ReversePointsOnCreditmemoSave.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\Sales\Model\Order\Creditmemo;
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\ResourceModel\PointsLedger\CollectionFactory;
use Psr\Log\LoggerInterface;

/**
 * Reverses previously earned loyalty points when a refund is finalized, using a
 * reconciliation (target minus already-booked) instead of a per-row flag, because
 * the ledger schema (chapter 3) has no creditmemo_id to key off of.
 */
class ReversePointsOnCreditmemoSave implements ObserverInterface
{
    /**
     * @param LoyaltyConfig $loyaltyConfig Typed configuration reader (chapter 7).
     * @param CollectionFactory $ledgerCollectionFactory Reads existing ledger entries for the reconciliation.
     * @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists the reversal entry.
     * @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates a new, unsaved ledger entry.
     * @param CustomerRepositoryInterface $customerRepository Loads and saves the customer's points balance.
     * @param LoggerInterface $logger Logs failures without letting them break the refund flow.
     */
    public function __construct(
        private readonly LoyaltyConfig $loyaltyConfig,
        private readonly CollectionFactory $ledgerCollectionFactory,
        private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
        private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly LoggerInterface $logger
    ) {
    }

    /**
     * @param EventObserver $observer Carries the saved credit memo as event data.
     * @return void
     */
    public function execute(EventObserver $observer): void
    {
        /** @var Creditmemo $creditmemo */
        $creditmemo = $observer->getEvent()->getData('creditmemo');

        try {
            $this->reversePoints($creditmemo);
        } catch (\Throwable $exception) {
            $this->logger->error(
                sprintf(
                    'Mironsoft_Loyalty: failed to reverse points for creditmemo #%s: %s',
                    (string) $creditmemo->getIncrementId(),
                    $exception->getMessage()
                ),
                ['exception' => $exception]
            );
        }
    }

    /**
     * Reconciles how many points should be reversed in total for the order's
     * cumulative refunded amount, and books only the delta not yet reversed.
     *
     * @param Creditmemo $creditmemo The just-saved credit memo.
     * @return void
     */
    private function reversePoints(Creditmemo $creditmemo): void
    {
        if ($creditmemo->getState() !== Creditmemo::STATE_REFUNDED) {
            return;
        }

        $order = $creditmemo->getOrder();
        $customerId = (int) $order->getCustomerId();
        if ($order->getCustomerIsGuest() || $customerId === 0) {
            return;
        }

        $websiteId = (int) $order->getStore()->getWebsiteId();
        $pointsPerEuro = $this->loyaltyConfig->getPointsPerEuro($websiteId);
        $orderId = (int) $order->getEntityId();

        // Simplification vs. chapter 30: refund reversal uses the flat, order-wide
        // points-per-euro rate, not the per-item product multiplier or category
        // bonus a refunded item may originally have earned extra points from.
        $pointsOwedForRefund = (int) floor((float) $order->getTotalRefunded() * $pointsPerEuro);
        $alreadyReversed = $this->sumAdjustPointsForOrder($orderId);
        $delta = $pointsOwedForRefund - $alreadyReversed;

        if ($delta <= 0) {
            return; // nothing new to reverse, e.g. a comment re-saved this creditmemo
        }

        $customer = $this->customerRepository->getById($customerId);
        $currentAttribute = $customer->getCustomAttribute('loyalty_points_balance');
        $currentBalance = $currentAttribute !== null ? (int) $currentAttribute->getValue() : 0;
        $pointsToReverse = min($delta, $currentBalance);
        if ($pointsToReverse <= 0) {
            return;
        }
        $newBalance = $currentBalance - $pointsToReverse;

        // TYPE_ADJUST is the closest fit among the four fixed types from chapter 6:
        // this is a system-triggered correction, not a customer-initiated redemption.
        $ledgerEntry = $this->pointsLedgerFactory->create();
        $ledgerEntry->setCustomerId($customerId);
        $ledgerEntry->setOrderId($orderId);
        $ledgerEntry->setType(PointsLedgerInterface::TYPE_ADJUST);
        $ledgerEntry->setPoints(-$pointsToReverse);
        $ledgerEntry->setBalanceAfter($newBalance);
        $this->pointsLedgerRepository->save($ledgerEntry);

        $customer->setCustomAttribute('loyalty_points_balance', $newBalance);
        $this->customerRepository->save($customer);
    }

    /**
     * Sums the absolute value of every negative "adjust" ledger entry already
     * booked for this order - the "already reversed" side of the reconciliation.
     *
     * @param int $orderId Order entity ID.
     * @return int
     */
    private function sumAdjustPointsForOrder(int $orderId): int
    {
        $collection = $this->ledgerCollectionFactory->create();
        $collection->addOrderFilter($orderId);
        $collection->addFieldToFilter('type', ['eq' => PointsLedgerInterface::TYPE_ADJUST]);

        return (int) abs(array_sum($collection->getColumnValues('points')));
    }
}

Tipp: Dieses Soll-Ist-Muster - berechne, was insgesamt gebucht sein müsste, ziehe ab, was bereits gebucht wurde, buche nur die Differenz - ist robuster als jeder Versuch, sich auf interne Magento-Flags wie isObjectNew() zu verlassen, deren genaues Timing beim Speichern schwer zuverlässig vorherzusagen ist. Kapitel 33 nutzt exakt dasselbe Muster für den Punkte-Ablauf-Cronjob - aus demselben Grund: Selbstheilung bei mehrfacher Ausführung, ohne eine zusätzliche Referenzspalte im Ledger-Schema zu benötigen.

Kapitel 32 wechselt jetzt vom Event- zum Cron-System: Bevor Punkte automatisch verfallen können (Kapitel 33), braucht dieser Cronjob eine eigene Crongroup.