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