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