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

Watching Refunds: Reversing Points on Credit Memo Creation

Watching Refunds: Reversing Points on Credit Memo Creation

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

A customer orders, receives points (chapter 30) - and sends the goods back. Without a countermeasure, they simply keep the points credited for it. This chapter watches credit memos and books the corresponding points back - and runs into a trap only foreshadowed in chapter 29: events that fire more than once.

save_after or save_commit_after?

Like every AbstractModel entity, Magento\Sales\Model\Order\Creditmemo fires sales_order_creditmemo_save_after inside the database transaction, before it commits - plus sales_order_creditmemo_save_commit_after, which only fires after a successful commit. For a financial action like reversing points, the latter is the right choice: if the transaction still fails later on (say, a deadlock or a later exception in the same request), the credit memo was never actually persisted - and no point reversal should have happened either.

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>

The trap: one event, multiple triggers

Achtung: sales_order_creditmemo_save_commit_after doesn't only fire when a credit memo is created, but on every subsequent save of that same credit memo entity - for example when a comment gets added to an existing credit memo in the admin ($creditmemo->addComment(...) followed by another save()). A naive observer that simply reverses points again on every call produces a double - or triple - point correction, even though only a single real refund ever happened.

The ledger schema from chapter 3 deliberately stores no creditmemo_id - it only knows order_id. A row-exact "have I already processed this one credit memo" check is therefore impossible without retroactively changing the ledger schema fixed across eleven chapters - deliberately out of scope for this chapter. The solution: instead of checking row by row, every call recalculates how many points should be reversed for the order's cumulative refunded amount so far - and only books the delta against what's already been booked. A second, third, or tenth call for the same credit memo then simply books zero.

Extending the collection: addOrderFilter()

Chapter 4 already has addCustomerFilter(). The reconciliation needs an equivalent filter by order - added to the Collection class without touching the existing PointsLedgerRepositoryInterface (chapter 6).

// Addition 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: This reconciliation pattern - calculate what the total booking should be, subtract what's already booked, book only the difference - is more robust than trying to rely on internal Magento flags like isObjectNew(), whose exact timing on save is hard to predict reliably. Chapter 33 uses exactly the same pattern for the point expiry cron job, for the same reason: self-healing under repeated execution, without needing an extra reference column in the ledger schema.

Chapter 32 now switches from the event system to cron: before points can expire automatically (chapter 33), this cron job needs its own crongroup.