API-Endpunkt zum Einlösen einer Prämie
API-Endpunkt zum Einlösen einer Prämie
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Kapitel 80 hat einen rein lesenden Endpunkt gebaut. Dieses Kapitel liefert den ersten schreibenden: POST /V1/loyalty/rewards/:rewardId/redeem löst eine Prämie gegen den Punktestand des eingeloggten Kunden ein. Die eigentliche Geschäftslogik landet bewusst nicht im Controller-artigen Webapi-Aufruf selbst, sondern in einer eigenen Service-Klasse - aus gutem Grund: Kapitel 83 ruft exakt dieselbe Klasse aus einem GraphQL-Resolver auf, ohne eine einzige Zeile zu duplizieren.
Das Ergebnisobjekt: RewardRedemptionResultInterface
Statt eines nackten true/false liefert die Einlösung ein kleines Ergebnisobjekt zurück - wie viele Punkte abgebucht wurden und der neue Punktestand, damit ein Client (App, Checkout-Widget, GraphQL-Client) die Anzeige sofort aktualisieren kann, ohne einen zweiten Request nachzuschieben:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api\Data;
/**
* Data interface for the outcome of redeeming a reward - returned by both
* the REST endpoint (chapter 81) and the GraphQL mutation (chapter 83).
*/
interface RewardRedemptionResultInterface
{
public const REWARD_ID = 'reward_id';
public const POINTS_SPENT = 'points_spent';
public const POINTS_BALANCE_AFTER = 'points_balance_after';
public const REDEEMED_AT = 'redeemed_at';
/**
* @return int
*/
public function getRewardId(): int;
/**
* @param int $rewardId Redeemed reward's entity ID.
* @return $this
*/
public function setRewardId(int $rewardId): self;
/**
* @return int
*/
public function getPointsSpent(): int;
/**
* @param int $pointsSpent Points deducted for this redemption.
* @return $this
*/
public function setPointsSpent(int $pointsSpent): self;
/**
* @return int
*/
public function getPointsBalanceAfter(): int;
/**
* @param int $pointsBalanceAfter Customer's points balance right after this redemption.
* @return $this
*/
public function setPointsBalanceAfter(int $pointsBalanceAfter): self;
/**
* @return string
*/
public function getRedeemedAt(): string;
/**
* @param string $redeemedAt Timestamp the redemption was booked at.
* @return $this
*/
public function setRedeemedAt(string $redeemedAt): self;
}
Der Service Contract: RewardRedemptionManagementInterface
Eine einzige Methode, redeem(int $rewardId, int $customerId) - bewusst kein $reward/$customer-Objekt als Parameter, damit sowohl REST (das nur IDs aus URL/Token kennt) als auch GraphQL (das dieselben IDs aus $args/$context liest, Kapitel 83) den Aufruf ohne Zwischenschritt absetzen können:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\RewardRedemptionResultInterface;
/**
* Service contract for redeeming a loyalty reward - the single business-logic
* entry point shared, unchanged, by the REST endpoint (chapter 81) and the
* GraphQL mutation (chapter 83).
*/
interface RewardRedemptionManagementInterface
{
/**
* Redeems the given reward for the given customer.
*
* @param int $rewardId Reward entity ID to redeem.
* @param int $customerId Customer entity ID redeeming the reward.
* @return \Mironsoft\Loyalty\Api\Data\RewardRedemptionResultInterface
* @throws NoSuchEntityException If the reward does not exist or is inactive.
* @throws LocalizedException If the customer does not have enough points.
*/
public function redeem(int $rewardId, int $customerId): RewardRedemptionResultInterface;
}
Die Implementierung: Model\RewardRedemptionManagement
Vier Schritte, jeder davon baut auf einem bereits existierenden Service Contract auf: Prämie laden (RewardRepositoryInterface, Kapitel 79), Punktestand prüfen (CustomerRepositoryInterface-Custom-Attribute-Technik, Kapitel 30), Ledger-Eintrag buchen (PointsLedgerRepositoryInterface, Kapitel 6), Kunden-Guthaben aktualisieren:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\Data\RewardRedemptionResultInterface;
use Mironsoft\Loyalty\Api\Data\RewardRedemptionResultInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface;
use Mironsoft\Loyalty\Api\RewardRepositoryInterface;
/**
* Redeems a loyalty reward against a customer's points balance - the one
* place this business rule lives, called identically from REST (chapter 81)
* and the GraphQL mutation (chapter 83).
*/
class RewardRedemptionManagement implements RewardRedemptionManagementInterface
{
/**
* @param RewardRepositoryInterface $rewardRepository Loads the reward being redeemed (chapter 79).
* @param CustomerRepositoryInterface $customerRepository Reads and updates the customer's balance.
* @param PointsLedgerRepositoryInterface $ledgerRepository Books the TYPE_REDEEM ledger entry (chapter 6).
* @param PointsLedgerInterfaceFactory $ledgerFactory Factory for a new ledger entry.
* @param RewardRedemptionResultInterfaceFactory $resultFactory Factory for the redemption result DTO.
* @param DateTime $dateTime Provides the redemption timestamp.
*/
public function __construct(
private readonly RewardRepositoryInterface $rewardRepository,
private readonly CustomerRepositoryInterface $customerRepository,
private readonly PointsLedgerRepositoryInterface $ledgerRepository,
private readonly PointsLedgerInterfaceFactory $ledgerFactory,
private readonly RewardRedemptionResultInterfaceFactory $resultFactory,
private readonly DateTime $dateTime,
) {
}
/**
* @inheritDoc
*/
public function redeem(int $rewardId, int $customerId): RewardRedemptionResultInterface
{
$reward = $this->rewardRepository->getById($rewardId);
if (!$reward->isActive()) {
throw new LocalizedException(__('This reward is no longer available.'));
}
$customer = $this->customerRepository->getById($customerId);
$balanceAttribute = $customer->getCustomAttribute('loyalty_points_balance');
$currentBalance = $balanceAttribute !== null ? (int) $balanceAttribute->getValue() : 0;
if ($currentBalance < $reward->getPointsCost()) {
throw new LocalizedException(
__('Not enough points to redeem this reward.')
);
}
// Known, deliberately unresolved limitation (same as chapter 63's
// RedeemPointsOnOrderPlaced): no SELECT ... FOR UPDATE lock against a
// concurrent redemption of the same customer's balance.
$newBalance = $currentBalance - $reward->getPointsCost();
$ledgerEntry = $this->ledgerFactory->create();
$ledgerEntry->setCustomerId($customerId);
$ledgerEntry->setType(PointsLedgerInterface::TYPE_REDEEM);
$ledgerEntry->setPoints(-$reward->getPointsCost());
$ledgerEntry->setBalanceAfter($newBalance);
$this->ledgerRepository->save($ledgerEntry);
$customer->setCustomAttribute('loyalty_points_balance', $newBalance);
$this->customerRepository->save($customer);
$result = $this->resultFactory->create();
$result->setRewardId($rewardId);
$result->setPointsSpent($reward->getPointsCost());
$result->setPointsBalanceAfter($newBalance);
$result->setRedeemedAt($this->dateTime->date('Y-m-d H:i:s'));
return $result;
}
}
Aktualisiert loyalty_points_balance exakt über dieselbe CustomerRepositoryInterface-Route wie AwardPointsOnOrderPlaced (Kapitel 30) und RedeemPointsOnOrderPlaced (Kapitel 63) - LoyaltyTierBackend::beforeSave() (Kapitel 26) berechnet dadurch auch hier automatisch die Treue-Stufe neu, ohne dass diese Klasse davon wissen muss.
Achtung: Genau dasselbe bekannte, bewusst nicht behobene Restrisiko wie bei RedeemPointsOnOrderPlaced (Kapitel 63): kein SELECT ... FOR UPDATE gegen eine parallele zweite Einlösung desselben Kunden im selben Moment - zwei gleichzeitige API-Aufrufe könnten theoretisch beide denselben, zum Zeitpunkt der jeweiligen Prüfung noch ausreichenden Punktestand "sehen" und beide Einlösungen durchführen. Für dieses Tutorial dokumentiert, in einem echten Produktivsystem ein Kandidat für eine Datenbank-Sperre oder eine optimistische Versionsprüfung.
webapi.xml und die Idempotenz-Frage
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/loyalty/rewards/:rewardId/redeem" method="POST">
<service class="Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface" method="redeem"/>
<resources>
<resource ref="self"/>
</resources>
<data>
<parameter name="customerId" force="true">%customer_id%</parameter>
</data>
</route>
</routes>
:rewardId im URL-Pfad wird automatisch an den ersten Parameter von redeem() gebunden (Reihenfolge nach Parameter-Name, nicht nach Deklarationsreihenfolge in der Methode), customerId kommt - wie schon in Kapitel 80 - erzwungen aus dem Token. method="POST" statt PUT ist hier die bewusst richtige Wahl: Eine Einlösung ist keine idempotente Operation (zweimaliges Absenden bucht zweimal Punkte ab), und PUT suggeriert laut HTTP-Semantik Idempotenz.
Den Endpunkt testen
curl -s -X POST https://mironsoft.test/rest/V1/loyalty/rewards/12/redeem \
-H 'Authorization: Bearer <token>'Mit REST-Lesen (Kapitel 80) und REST-Schreiben (dieses Kapitel) fertig, wendet sich Kapitel 82 GraphQL zu - und zeigt zunächst, wie sich derselbe Punktestand und der Prämienkatalog dort als Query abfragen lassen.