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

"Kostenloser Versand durch Punkte" als eigene Versandart

"Kostenloser Versand durch Punkte" als eigene Versandart

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

Das Gerüst aus Kapitel 66 bekommt jetzt seinen Rumpf: eine Versandart, die kostenlosen Versand anbietet, sobald der Punktestand des eingeloggten Kunden die konfigurierte Punktekosten-Schwelle erreicht.

app/code/Mironsoft/Loyalty/Model/Carrier/FreeShippingByPoints.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Carrier;

use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory;
use Magento\Quote\Model\Quote\Address\RateResult\MethodFactory;
use Magento\Shipping\Model\Carrier\AbstractCarrier;
use Magento\Shipping\Model\Carrier\CarrierInterface;
use Magento\Shipping\Model\Rate\Result;
use Magento\Shipping\Model\Rate\ResultFactory;
use Psr\Log\LoggerInterface;

/**
 * Offers free shipping to customers whose loyalty points balance covers the
 * configured points_cost - the shipping-side counterpart to the "Punkte
 * einlösen" payment method from chapters 62-65, following the same
 * "core-mandated base class, no duplicated business logic" pattern already
 * seen with ContentTypeAbstract (chapter 59) and the Adapter facade (chapter 62).
 */
class FreeShippingByPoints extends AbstractCarrier implements CarrierInterface
{
    /**
     * Carrier code, part of the resulting shipping_method string together with
     * self::METHOD_CODE (e.g. "mironsoft_loyalty_freeshipping_freeshipping").
     */
    protected $_code = 'mironsoft_loyalty_freeshipping';

    /**
     * The single shipping method this carrier offers.
     */
    public const string METHOD_CODE = 'freeshipping';

    /**
     * Config path for the points cost of unlocking free shipping, also read by
     * RedeemPointsOnOrderPlaced (this chapter's update to that observer).
     */
    public const string XML_PATH_POINTS_COST = 'carriers/mironsoft_loyalty_freeshipping/points_cost';

    /**
     * @param ScopeConfigInterface $scopedConfig Store-scoped configuration reader (required by AbstractCarrier)
     * @param ErrorFactory $rateErrorFactory Rate error factory (required by AbstractCarrier)
     * @param LoggerInterface $logger Logger (required by AbstractCarrier)
     * @param ResultFactory $rateResultFactory Factory for the shipping rate result container
     * @param MethodFactory $rateMethodFactory Factory for individual rate result methods
     * @param CustomerRepositoryInterface $customerRepository Customer repository for the points balance
     * @param array $data Additional carrier data (required by AbstractCarrier)
     */
    public function __construct(
        ScopeConfigInterface $scopedConfig,
        ErrorFactory $rateErrorFactory,
        LoggerInterface $logger,
        private readonly ResultFactory $rateResultFactory,
        private readonly MethodFactory $rateMethodFactory,
        private readonly CustomerRepositoryInterface $customerRepository,
        array $data = []
    ) {
        parent::__construct($scopedConfig, $rateErrorFactory, $logger, $data);
    }

    /**
     * Returns a free-shipping rate if the carrier is active, the destination is
     * allowed, and the customer's points balance covers the configured cost.
     *
     * @param RateRequest $request Shipping rate request
     * @return Result|bool
     */
    public function collectRates(RateRequest $request)
    {
        if (!$this->getConfigFlag('active')) {
            return false;
        }

        if (!$this->checkAvailableShipCountries($request)) {
            return false;
        }

        $customerId = (int) $request->getData('customer_id');

        if ($customerId <= 0 || !$this->hasEnoughPoints($customerId)) {
            return false;
        }

        /** @var Result $result */
        $result = $this->rateResultFactory->create();

        $method = $this->rateMethodFactory->create();
        $method->setCarrier($this->_code);
        $method->setCarrierTitle($this->getConfigData('title'));
        $method->setMethod(self::METHOD_CODE);
        $method->setMethodTitle($this->getConfigData('name'));
        $method->setPrice(0);
        $method->setCost(0);

        $result->append($method);

        return $result;
    }

    /**
     * Checks whether the customer's current points balance covers the configured cost.
     *
     * @param int $customerId Customer entity ID
     * @return bool
     */
    private function hasEnoughPoints(int $customerId): bool
    {
        $pointsCost = (int) $this->getConfigData('points_cost');
        $customer = $this->customerRepository->getById($customerId);
        $balance = (int) $customer->getCustomAttribute('loyalty_points_balance')?->getValue();

        return $balance >= $pointsCost;
    }

    /**
     * Returns the codes and titles of all shipping methods this carrier can offer.
     *
     * @return string[]
     */
    public function getAllowedMethods(): array
    {
        return [self::METHOD_CODE => $this->getConfigData('name')];
    }
}

Woher die customer_id kommt

Magento\Quote\Model\Quote\Address::requestShippingRates() setzt customer_id bereits auf dem RateRequest-Objekt, bevor collectRates() überhaupt aufgerufen wird - kein zusätzlicher Repository-Umweg über die Adresse nötig, um an den Kunden zu kommen. Ein Gast ohne Login liefert hier 0, und hasEnoughPoints() wird für ihn konsequent nie aufgerufen.

Die Buchung: Observer aus Kapitel 63 nachrüsten

Genau wie bei der Zahlungsart bucht collectRates() selbst nichts - es läuft potenziell mehrfach pro Seitenaufruf (Versandkosten-Schätzung im Warenkorb, jede Adressänderung im Checkout). Die Buchung gehört an denselben einen, garantiert einmaligen Zeitpunkt wie die Punkte-Zahlungsart: sales_order_place_after. RedeemPointsOnOrderPlaced aus Kapitel 63 bekommt dafür eine zweite private Methode, redeemForShipping() - kein neuer Observer, keine neue events.xml-Ergänzung, da das Event bereits registriert ist:

app/code/Mironsoft/Loyalty/Observer/RedeemPointsOnOrderPlaced.php (vollständig, erweitert Kapitel 63)
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Observer;

use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Event\Observer as EventObserver;
use Magento\Framework\Event\ObserverInterface;
use Magento\Quote\Api\CartRepositoryInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Store\Model\ScopeInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\Carrier\FreeShippingByPoints;
use Psr\Log\LoggerInterface;

/**
 * Books the loyalty points a customer redeemed as a checkout payment discount
 * (chapter 63) AND/OR as free shipping (this chapter) once the resulting order
 * has actually been placed. Both redemption paths funnel through the same
 * bookRedemption() helper and the same TYPE_REDEEM ledger entry type.
 */
class RedeemPointsOnOrderPlaced implements ObserverInterface
{
    /**
     * @param CartRepositoryInterface $cartRepository Quote repository, needed to read the redeemed points amount
     * @param CustomerRepositoryInterface $customerRepository Customer repository for balance updates
     * @param PointsLedgerRepositoryInterface $ledgerRepository Points ledger repository
     * @param PointsLedgerInterfaceFactory $ledgerFactory Factory for new ledger entries
     * @param ScopeConfigInterface $scopeConfig Store-scoped configuration reader, for the carrier's points_cost
     * @param LoggerInterface $logger Loyalty-specific error logger
     */
    public function __construct(
        private readonly CartRepositoryInterface $cartRepository,
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly PointsLedgerRepositoryInterface $ledgerRepository,
        private readonly PointsLedgerInterfaceFactory $ledgerFactory,
        private readonly ScopeConfigInterface $scopeConfig,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Books points redeemed as a payment discount and/or as free shipping.
     *
     * @param EventObserver $observer Event observer carrying the placed order
     * @return void
     */
    public function execute(EventObserver $observer): void
    {
        /** @var OrderInterface $order */
        $order = $observer->getEvent()->getData('order');

        if (!$order->getCustomerId()) {
            return;
        }

        try {
            $this->redeemForPayment($order);
            $this->redeemForShipping($order);
        } catch (\Throwable $exception) {
            $this->logger->error(
                'Einlösung von Treuepunkten fehlgeschlagen.',
                ['exception' => $exception, 'order_id' => $order->getEntityId()]
            );
        }
    }

    /**
     * Books the points that were applied as a payment discount during checkout
     * (chapter 63's ApplyPoints controller). Unchanged from chapter 63.
     *
     * @param OrderInterface $order Placed order
     * @return void
     */
    private function redeemForPayment(OrderInterface $order): void
    {
        $quote = $this->cartRepository->get((int) $order->getQuoteId());
        $pointsToRedeem = (int) $quote->getData('loyalty_points_to_redeem');

        if ($pointsToRedeem <= 0) {
            return;
        }

        $order->setData('loyalty_points_redeemed', $pointsToRedeem);
        $this->bookRedemption((int) $order->getCustomerId(), (int) $order->getEntityId(), $pointsToRedeem);
    }

    /**
     * Books the points spent on the free-shipping-by-points carrier, if the
     * order actually used it - new in this chapter.
     *
     * @param OrderInterface $order Placed order
     * @return void
     */
    private function redeemForShipping(OrderInterface $order): void
    {
        $shippingMethod = (string) $order->getShippingMethod();
        $expected = 'mironsoft_loyalty_freeshipping_' . FreeShippingByPoints::METHOD_CODE;

        if ($shippingMethod !== $expected) {
            return;
        }

        $pointsCost = (int) $this->scopeConfig->getValue(
            FreeShippingByPoints::XML_PATH_POINTS_COST,
            ScopeInterface::SCOPE_STORE,
            $order->getStoreId()
        );

        $this->bookRedemption((int) $order->getCustomerId(), (int) $order->getEntityId(), $pointsCost);
    }

    /**
     * Writes a TYPE_REDEEM ledger entry and decrements the customer's points
     * balance, using the same CustomerRepositoryInterface custom-attribute
     * technique as AwardPointsOnOrderPlaced (chapter 30) - LoyaltyTierBackend
     * (chapter 26) recalculates the tier automatically on save. Called once per
     * redemption channel, so a single order that both paid with points AND used
     * free shipping produces two separate TYPE_REDEEM ledger rows.
     *
     * @param int $customerId Customer entity ID
     * @param int $orderId Order entity ID
     * @param int $points Points to deduct
     * @return void
     */
    private function bookRedemption(int $customerId, int $orderId, int $points): void
    {
        $customer = $this->customerRepository->getById($customerId);
        $currentBalance = (int) $customer->getCustomAttribute('loyalty_points_balance')?->getValue();
        $newBalance = max(0, $currentBalance - $points);

        /** @var PointsLedgerInterface $ledgerEntry */
        $ledgerEntry = $this->ledgerFactory->create();
        $ledgerEntry->setCustomerId($customerId);
        $ledgerEntry->setOrderId($orderId);
        $ledgerEntry->setPoints(-$points);
        $ledgerEntry->setType(PointsLedgerInterface::TYPE_REDEEM);
        $ledgerEntry->setBalanceAfter($newBalance);
        $this->ledgerRepository->save($ledgerEntry);

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

Tipp: Der zusammengesetzte shipping_method-String auf der Bestellung hat immer die Form <carrier_code>_<method_code> - hier also mironsoft_loyalty_freeshipping_freeshipping. Ein Vergleich gegen nur $_code allein würde bei Carriern mit mehreren Methoden (Kapitel 68) fälschlich auch andere Methoden desselben Carriers erfassen.

Adminkonfiguration

Analog zu Kapitel 64 - nur unter der bestehenden carriers-Sektion statt payment, und mit den beiden für Versandarten typischen Länderfeldern:

app/code/Mironsoft/Loyalty/etc/adminhtml/system.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="carriers">
            <group id="mironsoft_loyalty_freeshipping" translate="label" type="text"
                   sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
                <label>Mironsoft Loyalty - Kostenloser Versand durch Punkte</label>
                <field id="active" translate="label" type="select" sortOrder="10"
                       showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Aktiviert</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="name" translate="label" type="text" sortOrder="20"
                       showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Methodenname</label>
                </field>
                <field id="title" translate="label" type="text" sortOrder="30"
                       showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Titel</label>
                </field>
                <field id="points_cost" translate="label" type="text" sortOrder="40"
                       showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Punktekosten</label>
                </field>
                <field id="sallowspecific" translate="label" type="select" sortOrder="50"
                       showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Versand nach anwendbaren Ländern</label>
                    <frontend_class>shipping-applicable-country</frontend_class>
                    <source_model>Magento\Shipping\Model\Config\Source\Allspecificcountries</source_model>
                </field>
                <field id="specificcountry" translate="label" type="multiselect" sortOrder="60"
                       showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Länder</label>
                    <source_model>Magento\Directory\Model\Config\Source\Country</source_model>
                    <can_be_empty>1</can_be_empty>
                </field>
                <field id="sort_order" translate="label" type="text" sortOrder="70"
                       showInDefault="1" showInWebsite="1" showInStore="1">
                    <label>Sortierreihenfolge</label>
                </field>
            </group>
        </section>
    </system>
</config>

Achtung: Kein neues ACL-Bedürfnis: Die Konfigurationsseite unter Stores > Configuration > Sales > Shipping Methods gehört zu Magento_Shippings eigener ACL-Ressource, nicht zu Mironsoft_Loyalty::config_section aus Kapitel 1 - anders als bei Kapitel 7s eigener Konfigurationsseite braucht dieses Kapitel keine neue ACL-Deklaration.

Die Versandart funktioniert jetzt vollständig. Kapitel 68 geht noch einmal tiefer in Method und Result hinein - insbesondere für den Fall, dass ein Carrier künftig mehr als eine Methode gleichzeitig anbieten soll.