"Free Shipping via Points" as a Custom Carrier
"Free Shipping via Points" as a Custom Carrier
~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
The skeleton from chapter 66 now gets its body: a shipping method that offers free shipping as soon as the logged-in customer's points balance reaches the configured points cost threshold.
<?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')];
}
}
Where the customer_id comes from
Magento\Quote\Model\Quote\Address::requestShippingRates() already sets customer_id on the RateRequest object before collectRates() is even called - no extra repository detour through the address is needed to reach the customer. A guest with no login yields 0 here, and hasEnoughPoints() is consistently never called for them.
Booking: retrofitting chapter 63's observer
Just like the payment method, collectRates() itself books nothing - it potentially runs multiple times per page load (shipping cost estimate in the cart, every address change in checkout). Booking belongs at the same, guaranteed single point in time as the points payment method: sales_order_place_after. RedeemPointsOnOrderPlaced from chapter 63 gets a second private method for that, redeemForShipping() - no new observer, no new events.xml addition, since the event is already registered:
<?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: The composed shipping_method string on the order always has the form <carrier_code>_<method_code> - here mironsoft_loyalty_freeshipping_freeshipping. Comparing against only $_code alone would wrongly also match other methods of the same carrier once a carrier offers more than one (chapter 68).
Admin configuration
Analogous to chapter 64 - just under the existing carriers section instead of payment, and with the two country fields typical for shipping methods:
<?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: No new ACL need: the configuration page under Stores > Configuration > Sales > Shipping Methods belongs to Magento_Shipping's own ACL resource, not Mironsoft_Loyalty::config_section from chapter 1 - unlike chapter 7's own configuration page, this chapter needs no new ACL declaration.
The shipping method is now fully functional. Chapter 68 dives deeper into Method and Result - in particular for the case where a carrier should one day offer more than one method at once.