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

The "Redeem Points" Payment Method: Partial Payment Without Real Split Tender

The "Redeem Points" Payment Method: Partial Payment Without Real Split Tender

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

The request sounds harmless: "let a customer cover part of the order total with points and pay the rest normally by credit card." Technically it's anything but harmless - Magento only ever knows exactly ONE payment method per order. This chapter first explains honestly why, then builds the solution that works within that constraint.

Why not real split payment

Both Magento\Quote\Model\Quote\Payment and Magento\Sales\Model\Order\Payment are 1:1 relationships - one quote, one order, exactly one payment record. The entire checkout payment step (chapter 65) is built so the customer picks EXACTLY ONE payment method from a list, which is then authorized/captured. A real split tender - two parallel, independently authorized payment transactions for the same order - would require deep surgery on sales_order_payment, the invoicing logic, and every payment gateway adapter, without Magento offering a clean extension point for it. That's not a small thing you "just" bolt on via a plugin.

Achtung: Deliberate design decision: instead of a real split payment, the points redemption is modeled as a discount on the order total - exactly the mechanism ApplyPointsRedemptionToTotalsPlugin from chapter 39 already prepared, back then still "without a setting controller." This chapter delivers that controller. The actual mironsoft_loyalty_points payment method from chapter 62 is then only ever used in a single case: when the redeemed points have already reduced the order total to zero. At that point it's a completely ordinary, single, zero-amount payment method - not split payment, because in the end only ONE payment method was ever selected. If the points balance only covers part of the total, the customer instead picks the regular payment method (credit card, invoice, ...) for the already-reduced remainder - the discount mechanism handles the entire "partial payment" before a payment method is even chosen.

Applying points before a payment method is chosen

Chapter 39 already reads $quote->getData('loyalty_points_to_redeem'), but with no setter. That's what's missing here: a new, real column directly on the quote table, added via db_schema.xml - declarative schema works just as well on foreign core tables as on this module's own (chapter 3), as long as Magento_Quote is listed in the module's own module.xml sequence:

app/code/Mironsoft/Loyalty/etc/db_schema.xml
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="quote" resource="checkout" engine="innodb" comment="Sales Flat Quote">
        <column xsi:type="int" name="loyalty_points_to_redeem" unsigned="true" nullable="true"
                default="0" comment="Loyalty points the customer wants to redeem as a checkout discount"/>
    </table>
</schema>

A slim AJAX controller in the checkout context sets that column. Deliberately NO AccountInterface like History\Index (chapter 45) or Redeem\Index (chapter 50, responsible there for redeeming a reward in the reward catalog - a different feature than this checkout controller): an AccountInterface redirect to the login page would break the AJAX call coming from inside checkout, instead of returning a clean 401 JSON response the knockout component from chapter 65 can act on.

app/code/Mironsoft/Loyalty/Controller/Ajax/ApplyPoints.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Controller\Ajax;

use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Controller\ResultInterface;
use Magento\Quote\Api\CartRepositoryInterface;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Psr\Log\LoggerInterface;

/**
 * Persists how many loyalty points a logged-in customer wants to redeem as a
 * checkout discount on the currently active quote. Chapter 39's totals plugin
 * then turns this stored value into the actual grand total reduction.
 */
class ApplyPoints implements HttpPostActionInterface, CsrfAwareActionInterface
{
    /**
     * @param RequestInterface $request Current HTTP request
     * @param JsonFactory $resultJsonFactory Factory for JSON responses
     * @param CustomerSession $customerSession Frontend customer session
     * @param CheckoutSession $checkoutSession Checkout session holding the active quote
     * @param CartRepositoryInterface $cartRepository Quote repository, needed to persist the change
     * @param LoyaltyConfig $loyaltyConfig Loyalty module configuration reader
     * @param LoggerInterface $logger Loyalty-specific error logger
     */
    public function __construct(
        private readonly RequestInterface $request,
        private readonly JsonFactory $resultJsonFactory,
        private readonly CustomerSession $customerSession,
        private readonly CheckoutSession $checkoutSession,
        private readonly CartRepositoryInterface $cartRepository,
        private readonly LoyaltyConfig $loyaltyConfig,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Validates the requested points amount and stores it on the active quote.
     *
     * @return ResultInterface
     */
    public function execute(): ResultInterface
    {
        $result = $this->resultJsonFactory->create();

        if (!$this->customerSession->isLoggedIn()) {
            return $result->setHttpResponseCode(401)
                ->setData(['success' => false, 'message' => 'Login erforderlich.']);
        }

        $requestedPoints = (int) $this->request->getParam('points', 0);
        $balance = (int) $this->customerSession->getCustomer()->getData('loyalty_points_balance');

        if ($requestedPoints < 0 || $requestedPoints > $balance) {
            return $result->setData(['success' => false, 'message' => 'Ungültige Punktzahl.']);
        }

        try {
            $quote = $this->checkoutSession->getQuote();
            $quote->setData('loyalty_points_to_redeem', $requestedPoints);
            $this->cartRepository->save($quote);
        } catch (\Throwable $exception) {
            $this->logger->error(
                'Punkte konnten nicht auf den Warenkorb angewendet werden.',
                ['exception' => $exception]
            );
            return $result->setData(['success' => false, 'message' => 'Technischer Fehler.']);
        }

        return $result->setData([
            'success' => true,
            'points_applied' => $requestedPoints,
            'discount_amount' => round($requestedPoints / $this->loyaltyConfig->getPointsPerEuro(), 2),
        ]);
    }

    /**
     * Declines to build a dedicated CSRF exception - Magento's default form-key
     * validation, shared with the rest of this module's POST controllers (chapter
     * 50), is sufficient here.
     *
     * @param RequestInterface $request Current HTTP request
     * @return InvalidRequestException|null
     */
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null;
    }

    /**
     * Confirms that standard CSRF validation should run for this action.
     *
     * @param RequestInterface $request Current HTTP request
     * @return bool|null
     */
    public function validateForCsrf(RequestInterface $request): ?bool
    {
        return null;
    }
}

Tipp: Controller\Ajax\ApplyPoints needs no routes.xml entry and no addition to the Router from chapter 46 - the standard routing convention frontName/folder/action resolves mironsoft_loyalty/ajax/applypoints automatically through the frontName="mironsoft_loyalty" already registered in chapter 46. Only the pretty, SEO-relevant URLs (/rewards/...) need the custom router.

Only booking for real once the order is placed

Just like ApplyPointsRedemptionToTotalsPlugin (chapter 39) runs multiple times per request and deliberately never books a ledger entry, the new controller must not trigger a booking either - it only records an intent. Booking only happens once that intent has actually turned into an order. For that, the order itself still needs an attribute that permanently records the points actually redeemed (an audit purpose, independent of the ledger):

app/code/Mironsoft/Loyalty/Setup/Patch/Data/InstallSalesLoyaltyRedeemedAttribute.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Sales\Setup\SalesSetupFactory;

/**
 * Adds the loyalty_points_redeemed attribute to sales_order - the redemption-side
 * counterpart to loyalty_points_earned from chapter 23 (order/order_item).
 * Order-level only, since redemption is decided once per order, not per line.
 */
class InstallSalesLoyaltyRedeemedAttribute implements DataPatchInterface
{
    /**
     * @param ModuleDataSetupInterface $moduleDataSetup Data setup instance
     * @param SalesSetupFactory $salesSetupFactory Factory for the Sales module's setup helper
     */
    public function __construct(
        private readonly ModuleDataSetupInterface $moduleDataSetup,
        private readonly SalesSetupFactory $salesSetupFactory,
    ) {
    }

    /**
     * Registers the order-level attribute for redeemed loyalty points.
     *
     * @return void
     */
    public function apply(): void
    {
        $this->moduleDataSetup->getConnection()->startSetup();

        $salesSetup = $this->salesSetupFactory->create(['setup' => $this->moduleDataSetup]);
        $salesSetup->addAttribute('order', 'loyalty_points_redeemed', [
            'type' => 'int',
            'visible' => false,
            'default' => 0,
        ]);

        $this->moduleDataSetup->getConnection()->endSetup();
    }

    /**
     * Declares this patch depends on chapter 23's sales attribute installer.
     *
     * @return string[]
     */
    public static function getDependencies(): array
    {
        return [InstallSalesLoyaltyAttributes::class];
    }

    /**
     * Declares no aliases for this patch.
     *
     * @return string[]
     */
    public function getAliases(): array
    {
        return [];
    }
}

And the observer that performs the actual booking - deliberately kept in its own class instead of being crammed into AwardPointsOnOrderPlaced (chapter 30), even though both listen on the same event:

app/code/Mironsoft/Loyalty/Observer/RedeemPointsOnOrderPlaced.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\Quote\Api\CartRepositoryInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Psr\Log\LoggerInterface;

/**
 * Books the loyalty points a customer redeemed as a checkout payment discount
 * once the resulting order has actually been placed - not earlier, for the same
 * idempotency reason ReversePointsOnCreditmemoSave (chapter 31) and ExpirePoints
 * (chapter 33) only book at a well-defined, single-fire point in time.
 */
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 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 LoggerInterface $logger,
    ) {
    }

    /**
     * Reads the redeemed points amount from the order's quote and books it.
     * Wrapped in try/catch for the same reason AwardPointsOnOrderPlaced (chapter
     * 30) is: events.xml observers run synchronously, and an unhandled exception
     * here would abort the checkout request after the order already exists.
     *
     * @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);
        } 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).
     *
     * @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);
    }

    /**
     * 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.
     *
     * @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);
    }
}
app/code/Mironsoft/Loyalty/etc/events.xml (excerpt, extends chapter 30)
<?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"/>
        <observer name="mironsoft_loyalty_redeem_points_on_order_placed"
                  instance="Mironsoft\Loyalty\Observer\RedeemPointsOnOrderPlaced"/>
    </event>
    <!-- sales_order_creditmemo_save_commit_after (chapter 31) unchanged, omitted here for brevity -->
</config>

Achtung: A known, deliberately accepted residual risk: if a customer opens the same checkout in two browser tabs at once and completes both orders nearly simultaneously, bookRedemption() can theoretically read the same, not-yet-updated currentBalance in both calls - a classic race condition. A watertight fix would need a database lock (SELECT ... FOR UPDATE) around the entire read-compute-write block. Deliberately not implemented for this tutorial module - on a real production system with heavy concurrent checkout traffic, that would be the next step.

The payment method itself - when it even becomes visible - is only settled in chapter 64: isAvailable() must recognize exactly the "points already cover everything" case argued for here.