Die "Punkte einlösen"-Zahlungsart: Teilzahlung ohne echtes Split-Payment
Die "Punkte einlösen"-Zahlungsart: Teilzahlung ohne echtes Split-Payment
~9 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Der Wunsch klingt harmlos: "Ein Kunde soll einen Teil der Bestellsumme mit Punkten decken, den Rest ganz normal mit Kreditkarte bezahlen." Technisch ist das alles andere als harmlos - Magento kennt pro Bestellung genau EINE Zahlungsart. Dieses Kapitel erklärt zuerst ehrlich, warum, und baut dann die Lösung, die innerhalb dieser Grenze funktioniert.
Warum kein echtes Split-Payment
Sowohl Magento\Quote\Model\Quote\Payment als auch Magento\Sales\Model\Order\Payment sind 1:1-Beziehungen - ein Quote, eine Order, genau ein Payment-Datensatz. Der komplette Checkout-Payment-Step (Kapitel 65) ist so gebaut, dass der Kunde aus einer Liste GENAU EINE Zahlungsart auswählt, die dann autorisiert/erfasst wird. Ein echtes Split-Tender - zwei parallele, unabhängig autorisierte Zahlungstransaktionen für dieselbe Bestellung - hätte tiefe Eingriffe in sales_order_payment, die Rechnungslogik und jeden Zahlungsart-Gateway-Adapter zur Folge, ohne dass Magento dafür einen sauberen Erweiterungspunkt anbietet. Das ist keine Kleinigkeit, die "man mal eben" per Plugin nachrüstet.
Achtung: Bewusste Design-Entscheidung: Statt eines echten Split-Payments wird die Punkte-Einlösung als Rabatt auf den Bestellwert modelliert - genau der Mechanismus, den ApplyPointsRedemptionToTotalsPlugin aus Kapitel 39 bereits vorbereitet hat, damals noch "ohne setzenden Controller". Dieses Kapitel liefert diesen Controller nach. Die eigentliche Zahlungsart mironsoft_loyalty_points aus Kapitel 62 kommt dadurch nur noch in einem einzigen Fall zum Einsatz: wenn die eingelösten Punkte den kompletten Bestellwert bereits auf null reduziert haben. Dann ist sie ein ganz normales, einzelnes Zahlungsverfahren mit Betrag null - kein Split-Payment, weil am Ende trotzdem nur EINE Zahlungsart gewählt wurde. Reicht der Punktestand dagegen nur für einen Teil, wählt der Kunde stattdessen die reguläre Zahlungsart (Kreditkarte, Rechnung, ...) für den bereits reduzierten Restbetrag - der Rabatt-Mechanismus erledigt die "Teilzahlung" vollständig, bevor überhaupt eine Zahlungsart gewählt wird.
Punkte anwenden, bevor eine Zahlungsart gewählt wird
Kapitel 39 liest bereits $quote->getData('loyalty_points_to_redeem'), aber ohne Setter. Der fehlt hier: eine neue, echte Spalte direkt auf der quote-Tabelle, ergänzt über db_schema.xml - deklaratives Schema funktioniert genauso gut auf fremden Kern-Tabellen wie auf eigenen (Kapitel 3), solange Magento_Quote in der eigenen module.xml-sequence steht:
<?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>
Ein schlanker AJAX-Controller im Checkout-Kontext setzt diese Spalte. Bewusst KEIN AccountInterface wie bei History\Index (Kapitel 45) oder Redeem\Index (Kapitel 50, dort für die Prämien-Einlösung im Prämienkatalog zuständig - eine andere Funktion als dieser Checkout-Controller hier): Ein AccountInterface-Redirect zur Login-Seite würde den AJAX-Aufruf aus dem Checkout heraus zerstören, statt eine saubere 401-JSON-Antwort zu liefern, die die Knockout-Komponente aus Kapitel 65 auswerten kann.
<?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 braucht keinen Eintrag in routes.xml und keine Ergänzung am Router aus Kapitel 46 - die Standard-Routing-Konvention frontName/Ordner/Aktion löst mironsoft_loyalty/ajax/applypoints automatisch über das bereits in Kapitel 46 registrierte frontName="mironsoft_loyalty" auf. Nur die hübschen, SEO-relevanten URLs (/treuepraemien/...) brauchen den eigenen Router.
Erst bei Bestellabschluss wirklich buchen
Genau wie ApplyPointsRedemptionToTotalsPlugin (Kapitel 39) mehrfach pro Request läuft und deshalb bewusst keinen Ledger-Eintrag bucht, darf auch der neue Controller keine Buchung auslösen - er speichert nur eine Absicht. Gebucht wird erst, wenn aus dieser Absicht tatsächlich eine Bestellung geworden ist. Dafür braucht die Bestellung selbst noch ein Attribut, das die tatsächlich eingelösten Punkte dauerhaft festhält (Audit-Zweck, unabhängig vom Ledger):
<?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 [];
}
}
Und der Observer, der die eigentliche Buchung übernimmt - strukturell bewusst einer eigenen Klasse überlassen statt in AwardPointsOnOrderPlaced (Kapitel 30) hineingequetscht, auch wenn beide auf demselben Event lauschen:
<?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);
}
}
<?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: Ein bekanntes, bewusst hingenommenes Restrisiko: Öffnet ein Kunde denselben Checkout in zwei Browser-Tabs gleichzeitig und schließt beide Bestellungen nahezu zeitgleich ab, liest bookRedemption() in beiden Aufrufen theoretisch denselben, noch nicht aktualisierten currentBalance - ein klassisches Race Condition. Eine wasserdichte Lösung bräuchte eine Datenbank-Sperre (SELECT ... FOR UPDATE) um den gesamten Lese-Rechne-Schreib-Block. Für dieses Tutorial-Modul bewusst nicht umgesetzt - in einem echten Produktivsystem mit hohem parallelem Checkout-Aufkommen wäre das der nächste Schritt.
Die Zahlungsart selbst - wann sie überhaupt sichtbar wird - klärt erst Kapitel 64: isAvailable() muss genau den hier begründeten "Punkte decken bereits alles"-Fall erkennen.