Redeem-Controller: eine Prämie einlösen (POST-Action, CSRF, Validierung)
Redeem-Controller: eine Prämie einlösen (POST-Action, CSRF, Validierung)
~9 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Alle bisherigen Controller in Block 6 waren reine GET-Leseaktionen. Dieses Kapitel baut die erste schreibende Aktion: Redeem\Index bucht eine Einlösung gegen PointsLedgerRepositoryInterface (Kapitel 6) und aktualisiert den Punktestand des Kunden - dieselbe getCustomAttribute()/setCustomAttribute()-Technik wie in AwardPointsOnOrderPlaced (Kapitel 30), nur mit umgekehrtem Vorzeichen.
CsrfAwareActionInterface statt manueller Formkey-Prüfung
Magento\Framework\App\CsrfAwareActionInterface ist der seit Magento 2.3 empfohlene Weg, eine POST-Action CSRF-fähig zu machen. Beide Methoden dürfen null zurückgeben, um Magentos eingebautes Standardverhalten (Formkey-Session-Abgleich) zu übernehmen - genau das tut dieser Controller, es gibt keinen Grund, das Rad neu zu erfinden:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Controller\Redeem;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Customer\Controller\AccountInterface;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
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\Redirect;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Mironsoft\Loyalty\Model\ResourceModel\Reward\CollectionFactory as RewardCollectionFactory;
/**
* Redeems one reward for the logged-in customer: validates balance and reward
* state, appends an append-only TYPE_REDEEM ledger entry (chapter 6), and debits
* loyalty_points_balance on the customer (chapter 21). Implements AccountInterface
* for the same reason History\Index does (chapter 45) - redemption always requires
* a login, unlike browsing the catalog (chapter 49).
*/
class Index extends Action implements HttpPostActionInterface, CsrfAwareActionInterface, AccountInterface
{
/**
* @param Context $context Framework action context (redirect/message manager access).
* @param CustomerSession $customerSession Provides the logged-in customer ID.
* @param CustomerRepositoryInterface $customerRepository Loads and saves the customer's points balance.
* @param RewardCollectionFactory $rewardCollectionFactory Loads the reward being redeemed.
* @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists the redemption ledger entry (chapter 6).
* @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates a new, unsaved ledger entry.
* @param LoyaltyConfig $loyaltyConfig Confirms the loyalty program is enabled before redeeming.
*/
public function __construct(
Context $context,
private readonly CustomerSession $customerSession,
private readonly CustomerRepositoryInterface $customerRepository,
private readonly RewardCollectionFactory $rewardCollectionFactory,
private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
private readonly LoyaltyConfig $loyaltyConfig,
) {
parent::__construct($context);
}
/**
* Returning null defers to Magento's default CSRF exception (a 400 response).
*
* @param RequestInterface $request Current HTTP request.
* @return InvalidRequestException|null
*/
public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
{
return null;
}
/**
* Returning null defers to Magento's default form-key session validation - no
* custom CSRF logic needed for a standard, session-authenticated POST form.
*
* @param RequestInterface $request Current HTTP request.
* @return bool|null
*/
public function validateForCsrf(RequestInterface $request): ?bool
{
return null;
}
/**
* @return Redirect
*/
public function execute(): Redirect
{
/** @var Redirect $redirect */
$redirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);
$rewardId = (int) $this->getRequest()->getParam('reward_id');
if (!$this->loyaltyConfig->isEnabled() || $rewardId <= 0) {
$this->messageManager->addErrorMessage(__('This reward cannot be redeemed right now.'));
return $redirect->setPath('mironsoft_loyalty/catalog/index');
}
$reward = $this->rewardCollectionFactory->create()
->addActiveFilter()
->addFieldToFilter('entity_id', ['eq' => $rewardId])
->getFirstItem();
if (!$reward->getId()) {
$this->messageManager->addErrorMessage(__('This reward no longer exists.'));
return $redirect->setPath('mironsoft_loyalty/catalog/index');
}
try {
$this->redeem((int) $this->customerSession->getCustomerId(), (int) $reward->getPointsCost(), $rewardId);
$this->messageManager->addSuccessMessage(
__('You redeemed "%1" for %2 points.', $reward->getTitle(), $reward->getPointsCost())
);
} catch (LocalizedException $exception) {
$this->messageManager->addErrorMessage($exception->getMessage());
return $redirect->setPath('mironsoft_loyalty/catalog/view', ['reward_identifier' => $reward->getIdentifier()]);
}
return $redirect->setPath('mironsoft_loyalty/history/index');
}
/**
* Validates the balance and books the redemption as an append-only ledger entry
* plus a customer balance debit.
*
* @param int $customerId Customer entity ID.
* @param int $pointsCost Points required for the reward being redeemed.
* @param int $rewardId Reward entity ID, for the confirmation message only - the
* ledger schema (chapter 3) has no reward_id column, only order_id.
* @return void
* @throws LocalizedException If the balance is insufficient or the customer cannot be loaded.
*/
private function redeem(int $customerId, int $pointsCost, int $rewardId): void
{
try {
$customer = $this->customerRepository->getById($customerId);
} catch (NoSuchEntityException $exception) {
throw new LocalizedException(__('Your account could not be loaded.'), $exception);
}
$attribute = $customer->getCustomAttribute('loyalty_points_balance');
$currentBalance = $attribute !== null ? (int) $attribute->getValue() : 0;
if ($currentBalance < $pointsCost) {
throw new LocalizedException(__('You do not have enough points for this reward.'));
}
$newBalance = $currentBalance - $pointsCost;
/** @var PointsLedgerInterface $ledgerEntry */
$ledgerEntry = $this->pointsLedgerFactory->create();
$ledgerEntry->setCustomerId($customerId);
$ledgerEntry->setOrderId(null);
$ledgerEntry->setType(PointsLedgerInterface::TYPE_REDEEM);
$ledgerEntry->setPoints(-$pointsCost);
$ledgerEntry->setBalanceAfter($newBalance);
$ledgerEntry->setExpiresAt(null);
$this->pointsLedgerRepository->save($ledgerEntry);
$customer->setCustomAttribute('loyalty_points_balance', $newBalance);
// Saving through the repository re-triggers LoyaltyTierBackend::beforeSave()
// (chapter 26), which recalculates loyalty_tier from the new balance.
$this->customerRepository->save($customer);
}
}Warum die Route die technische URL bleibt
Das Formular auf der Detailseite postet bewusst an mironsoft_loyalty/redeem/index - die technische Route aus Kapitel 45, nicht an eine hübsche /treuepraemien/...-URL. Der Router aus Kapitel 46 fängt ohnehin nur GET-Navigationspfade ab; ein Formular-Ziel muss nicht SEO-freundlich sein, es wird nie verlinkt, nie indexiert und nie als Lesezeichen gespeichert.
<form method="post" action="<?= $escaper->escapeUrl($block->getUrl('mironsoft_loyalty/redeem/index')) ?>">
<input type="hidden" name="form_key" value="<?= $escaper->escapeHtmlAttr($viewModel->getFormKey()) ?>">
<input type="hidden" name="reward_id" value="<?= (int) $reward->getId() ?>">
<button type="submit"><?= $escaper->escapeHtml(__('Redeem now')) ?></button>
</form>Hyvä rendert keine Blocks per $block->getBlockHtml('formkey') (das wäre der Luma-Weg) - stattdessen liefert RewardDetail (Kapitel 49) einen zusätzlichen getFormKey(): string, der intern \Magento\Framework\Data\Form\FormKey::getFormKey() injiziert. Kapitel 53 zeigt das vollständige Template inklusive dieses Formulars.
// Ergänzung in Mironsoft\Loyalty\ViewModel\RewardDetail (Kapitel 49)
public function __construct(
private readonly RequestInterface $request,
private readonly CollectionFactory $rewardCollectionFactory,
private readonly CustomerSession $customerSession,
private readonly CustomerRepositoryInterface $customerRepository,
private readonly \Magento\Framework\Data\Form\FormKey $formKey,
) {
}
public function getFormKey(): string
{
return $this->formKey->getFormKey();
}Achtung: Ledger-Einträge sind laut Kapitel 6 append-only - dieser Controller lädt nie einen bestehenden Eintrag, um ihn zu ändern, sondern erzeugt immer einen neuen über pointsLedgerFactory->create(). Ein zweiter, ernster Punkt: getById() gefolgt von save() ist hier nicht transaktional gegen parallele Requests desselben Kunden abgesichert - zwei gleichzeitige Einlösungen könnten theoretisch beide dieselbe (veraltete) $currentBalance lesen und den Kunden ins Minus schicken. Für ein Tutorial-Modul bewusst offen gelassen; eine Produktivversion würde diese Sektion mit einer Datenbank-Transaktion und pessimistischem Locking (SELECT ... FOR UPDATE auf die Customer-Zeile über ResourceConnection) absichern, statt sich auf Repository-Aufrufe allein zu verlassen.
Tipp: Kapitel 51 verdrahtet Catalog\Index, Catalog\View und History\Index jetzt vollständig per Layout-XML - Redeem\Index braucht bewusst keine eigene Layout-Datei, dazu mehr im nächsten Kapitel.