vom Code bis zur sicheren Einlösung im Checkout
Gift Cards sind in Magento 2 mehr als ein einfaches Gutscheinfeld: ein sauberes Datenmodell im giftcardaccount, sichere Code-Generierung, eine korrekte Checkout-Total-Integration, Mehrfach-Einlösung pro Warenkorb, ein belastbarer Admin-Workflow für Batch-Kampagnen sowie eine gehärtete API-Absicherung für Headless- und GraphQL-Checkouts entscheiden darüber, ob Gutscheinkarten im Betrieb zuverlässig und sicher funktionieren.
Inhaltsverzeichnis
- 1. Gift Cards vs. Store Credit: klare Abgrenzung
- 2. Datenmodell: giftcardaccount, Code und Status
- 3. Code-Generierung und Sicherheit
- 4. Checkout-Integration: Quote Address Total Collector
- 5. Mehrere Gift-Card-Codes pro Warenkorb
- 6. Admin-Workflow: manuelle Erstellung und Batch-Generierung
- 7. API-Absicherung für Headless- und GraphQL-Checkouts
- 8. Refund-Handling: Rückgabe eines mit Gift Card bezahlten Produkts
- 9. Redemption-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Gift Cards vs. Store Credit: klare Abgrenzung
Bevor an der Implementierung gearbeitet wird, muss die Abgrenzung stehen: Dieser Artikel behandelt ausschließlich codebasierte Gift Cards, also einzelne Gutscheincodes mit einem eigenen, fest zugeordneten Guthaben. Ein Kunde erhält einen Code (physisch auf einer Plastikkarte, digital per E-Mail oder als PDF), gibt ihn im Checkout ein und das hinterlegte Guthaben wird gegen den Warenkorb verrechnet. Das ist etwas fundamental anderes als ein kontobezogenes Store-Credit-Guthaben, bei dem ein Betrag direkt dem Kundenkonto zugeschrieben wird, ohne dass ein einzelner Code im Spiel ist. Store Credit ist Gegenstand eines separaten Artikels und wird hier bewusst nicht behandelt.
Die Unterscheidung ist nicht nur begrifflich, sie hat direkte Auswirkungen auf das Datenmodell. Ein Gift Card Code lebt als eigenständige Entität mit eigenem Status, eigenem Restguthaben und eigenem Ablaufdatum, unabhängig vom Kundenkonto. Ein und derselbe Code kann im Prinzip von jedem eingelöst werden, der ihn kennt, was wiederum eigene Anforderungen an Sicherheit und Brute-Force-Schutz stellt, die ein kontobezogenes Guthaben so nicht hat.
Codebasierte Gift Cards sind die richtige Wahl, wenn physische oder digitale Gutscheinkarten als eigenständiges Produkt verkauft werden (etwa im Weihnachtsgeschäft oder als Firmenincentive), wenn Marketingkampagnen Codes verteilen sollen, die unabhängig von einem bestehenden Kundenkonto funktionieren, oder wenn ein Partnerprogramm Codes an Dritte ausgibt, die diese im Shop weiterverkaufen. Sobald das Guthaben dagegen fest an ein bestehendes Kundenkonto gebunden sein soll, etwa als Rückerstattung ohne neuen Code, ist ein Store-Credit-Modell die passendere Lösung, nicht die hier beschriebene Gutscheinkarte.
2. Datenmodell: giftcardaccount, Code und Status
Das Herzstück der Implementierung ist eine eigene Tabelle für den giftcardaccount. Sie speichert den Code selbst (gehasht oder zumindest mit einem eindeutigen Index versehen), das aktuelle Guthaben, den Ausgangsbetrag, den Status (aktiv, eingelöst, gesperrt, abgelaufen) sowie ein optionales Ablaufdatum. Wichtig ist, den Code nicht als reinen Klartext-String ohne Constraints zu speichern: ein UNIQUE-Index auf der Codespalte verhindert Duplikate bei der Batch-Generierung, und eine zusätzliche Spalte für den Status entkoppelt die Geschäftslogik von reinen Guthaben-Berechnungen.
Die Modellierung erfolgt über db_schema.xml, nicht über InstallScripts. Die Tabelle referenziert die order_id des Bestellvorgangs, der den Code ursprünglich erzeugt hat (etwa beim Kauf eines Gift-Card-Produkts), und hält in einer separaten Zuordnungstabelle fest, welche quote_id beziehungsweise welche order_id den Code zur Einlösung verwendet hat. Diese Trennung zwischen Erzeugung und Einlösung ist entscheidend: Ein Code kann in einer Bestellung entstehen und in einer völlig anderen, späteren Bestellung eingelöst werden, oft von einem anderen Kunden.
<?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="mironsoft_giftcardaccount" resource="default" engine="innodb" comment="Gift Card Account">
<column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/>
<column xsi:type="varchar" name="code" nullable="false" length="32" comment="Gift Card Code"/>
<column xsi:type="decimal" name="balance" scale="4" precision="20" unsigned="false" nullable="false" default="0" comment="Current Balance"/>
<column xsi:type="decimal" name="initial_balance" scale="4" precision="20" unsigned="false" nullable="false" comment="Initial Balance"/>
<column xsi:type="varchar" name="status" nullable="false" length="20" default="active" comment="Status: active, redeemed, disabled, expired"/>
<column xsi:type="date" name="expires_at" nullable="true" comment="Expiry Date"/>
<column xsi:type="int" name="generated_order_id" unsigned="true" nullable="true" comment="Order that generated this code"/>
<column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<constraint xsi:type="unique" referenceId="MIRONSOFT_GIFTCARDACCOUNT_CODE">
<column name="code"/>
</constraint>
<index referenceId="MIRONSOFT_GIFTCARDACCOUNT_STATUS" indexType="btree">
<column name="status"/>
</index>
</table>
<table name="mironsoft_giftcardaccount_redemption" resource="default" engine="innodb" comment="Gift Card Redemption History">
<column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/>
<column xsi:type="int" name="giftcardaccount_id" unsigned="true" nullable="false" comment="Gift Card Account ID"/>
<column xsi:type="int" name="quote_id" unsigned="true" nullable="true" comment="Quote ID at time of redemption"/>
<column xsi:type="int" name="order_id" unsigned="true" nullable="true" comment="Order ID after checkout completion"/>
<column xsi:type="decimal" name="amount_used" scale="4" precision="20" unsigned="true" nullable="false" comment="Amount Consumed"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_GCA_REDEMPTION_GCA_ID"
table="mironsoft_giftcardaccount_redemption" column="giftcardaccount_id"
referenceTable="mironsoft_giftcardaccount" referenceColumn="entity_id" onDelete="CASCADE"/>
</table>
</schema>
Die Redemption-Tabelle ist bewusst von der Hauptaccount-Tabelle getrennt: Ein Code kann über mehrere Bestellungen hinweg teilweise eingelöst werden, und jede Teileinlösung erzeugt einen eigenen Datensatz. Damit lässt sich jederzeit nachvollziehen, wie viel Guthaben wann und in welcher Bestellung verbraucht wurde, ein Feature, das für Support-Anfragen und Buchhaltungsabgleich unverzichtbar ist.
3. Code-Generierung und Sicherheit
Der Code selbst ist der kritischste Sicherheitsaspekt einer Gift Card Implementierung. Sequenzielle oder vorhersagbare Codes (etwa fortlaufende Nummern mit Präfix) lassen sich systematisch durchprobieren, sobald ein Angreifer ein gültiges Format kennt. Die Generierung muss daher auf kryptographisch sicherem Zufall basieren, nicht auf mt_rand() oder ähnlichen vorhersagbaren Quellen. PHP 8.4 bietet dafür random_bytes(), dessen Ausgabe anschließend in ein lesbares Alphabet (ohne verwechselbare Zeichen wie 0/O oder 1/I/l) codiert wird.
Zusätzlich zur reinen Zufälligkeit gehört eine Prüfziffer (Checksum) zum robusten Code-Design. Sie erkennt Tippfehler bei der manuellen Eingabe, bevor überhaupt eine Datenbankabfrage nötig wird, und reduziert damit gleichzeitig die Last durch versehentliche Falscheingaben. Rate-Limiting auf der Einlöse-Route ist die zweite Verteidigungslinie: Ohne Begrenzung der Versuche pro IP-Adresse oder Kundensession lässt sich selbst ein sicher generierter Code durch massives Durchprobieren irgendwann erraten, insbesondere bei kurzen Codes ohne ausreichende Entropie.
<?php
declare(strict_types=1);
namespace Mironsoft\GiftCard\Model\Code;
use Mironsoft\GiftCard\Api\Data\GiftCardAccountInterfaceFactory;
use Mironsoft\GiftCard\Api\GiftCardAccountRepositoryInterface;
/**
* Generates cryptographically secure, checksum-protected gift card codes.
*/
final class SecureCodeGenerator
{
/** @var string Alphabet without ambiguous characters (0/O, 1/I/l excluded) */
private const string ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
/** @var int Number of random characters before the checksum digit */
private const int CODE_LENGTH = 16;
/**
* @param GiftCardAccountRepositoryInterface $accountRepository Repository used to guarantee uniqueness
* @param GiftCardAccountInterfaceFactory $accountFactory Factory for new gift card account entities
*/
public function __construct(
private readonly GiftCardAccountRepositoryInterface $accountRepository,
private readonly GiftCardAccountInterfaceFactory $accountFactory,
) {
}
/**
* Generates a unique gift card code with an appended checksum character.
*
* @return string The generated, human-readable gift card code
* @throws \Random\RandomException If a secure random source is unavailable
*/
public function generate(): string
{
do {
$code = $this->randomPayload() . $this->checksum($this->randomPayload());
} while ($this->accountRepository->existsByCode($code));
return $code;
}
/**
* Builds the random payload segment of the code using random_bytes().
*
* @return string A random string drawn from the safe alphabet
* @throws \Random\RandomException If a secure random source is unavailable
*/
private function randomPayload(): string
{
$alphabetLength = strlen(self::ALPHABET);
$payload = '';
foreach (str_split(bin2hex(random_bytes(self::CODE_LENGTH))) as $byte) {
$payload .= self::ALPHABET[hexdec($byte) % $alphabetLength];
}
return substr($payload, 0, self::CODE_LENGTH);
}
/**
* Computes a single checksum character to detect typos on manual entry.
*
* @param string $payload The random payload the checksum is derived from
* @return string A single checksum character from the safe alphabet
*/
private function checksum(string $payload): string
{
$hash = hash('crc32b', $payload);
$index = hexdec(substr($hash, 0, 2)) % strlen(self::ALPHABET);
return self::ALPHABET[$index];
}
}
Wichtig ist zudem, dass Codes niemals im Klartext in Logdateien oder Fehlerberichten auftauchen. Jede Log-Ausgabe, die einen Code enthält, sollte ihn maskieren (zum Beispiel nur die letzten vier Zeichen anzeigen), damit ein kompromittiertes Log-System nicht gleichzeitig zu einer Liste einlösbarer Gutscheinkarten wird. Diese Maskierung greift auch bei der Fehlerbehandlung in Admin und API, dazu mehr im Abschnitt zur API-Absicherung.
4. Checkout-Integration: Quote Address Total Collector
Die Verrechnung des Gift-Card-Guthabens im Checkout erfolgt über das Total-Collector-System von Magento, nicht über eine nachträgliche Preisanpassung. Eine eigene Klasse erweitert Magento\Quote\Model\Quote\Address\Total\AbstractTotal und implementiert die Methode collect(), die während der Totals-Berechnung aufgerufen wird. Innerhalb dieser Methode wird das eingelöste Guthaben von der Zwischensumme abgezogen und als eigener Posten (mit eigenem Label) in der Total-Zusammenstellung ausgewiesen, sodass er im Checkout, in der Rechnung und im Bestell-E-Mail konsistent sichtbar ist.
Die Registrierung der Total-Klasse erfolgt über totals.xml im jeweiligen Modul, mit einer expliziten Sortierreihenfolge. Das ist entscheidend: Der Gift-Card-Abzug muss nach Rabatten und Steuerberechnung, aber vor der finalen Grand-Total-Fixierung greifen, da sonst falsche Steuerbeträge oder doppelte Rabattierungen entstehen können. Eine zu früh positionierte Total-Klasse sieht unter Umständen einen Zwischenstand, der spätere Anpassungen (etwa durch Versandkosten-Rabatte) noch nicht enthält.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Quote:etc/totals.xsd">
<total name="giftcardaccount">
<class instance="Mironsoft\GiftCard\Model\Total\Quote\GiftCardAccount"/>
<before>grand_total</before>
<after>tax</after>
</total>
</config>
Auf PHP-Seite prüft die collect()-Methode zunächst, ob überhaupt eingelöste Codes an der Quote hängen, summiert deren nutzbares Guthaben, begrenzt den Abzug auf die aktuelle Zwischensumme (ein Gift-Card-Guthaben darf niemals eine negative Grand Total erzeugen) und schreibt den tatsächlich verbrauchten Betrag zurück in die Address-Totals. Der nicht verbrauchte Rest bleibt unangetastet im giftcardaccount-Datensatz stehen und wird erst bei der finalen Bestellabwicklung als tatsächlich konsumiert markiert, nicht schon bei der bloßen Anzeige im Checkout.
5. Mehrere Gift-Card-Codes pro Warenkorb
Anders als ein einzelner Rabattcoupon lassen sich Gift Cards in der Regel mehrfach pro Warenkorb kombinieren, ein Kunde kann also zwei oder drei Codes gleichzeitig einlösen, um eine größere Bestellung zu begleichen. Das erfordert eine eigene Zuordnungstabelle zwischen Quote und den eingelösten Codes (nicht nur ein einzelnes String-Feld an der Quote), da sonst weder das jeweils genutzte Teilguthaben pro Code noch die korrekte Rückabwicklung bei einem Storno der Bestellung nachvollziehbar wäre.
Bei der Teilbetrag-Logik gilt: Reicht das Guthaben eines Codes nicht aus, um die komplette Zwischensumme zu decken, wird der Code vollständig verbraucht und der nächste Code in der Reihenfolge der Anwendung herangezogen. Reicht ein Code hingegen aus und es bleibt Restguthaben übrig, wird nur der benötigte Teilbetrag abgebucht, der Rest verbleibt für zukünftige Bestellungen auf dem Code. Diese Reihenfolge muss deterministisch sein (zum Beispiel nach Anwendungszeitpunkt), damit bei parallelen Requests keine Race Conditions zu inkonsistenten Restguthaben führen.
Ein oft übersehener Punkt ist die quote_id_mask bei Guest-Checkouts und bei GraphQL-basierten Headless-Frontends: Die maskierte Quote-ID aus der API muss vor jeder Redemption-Operation in die echte quote_id aufgelöst werden, da die Zuordnungstabelle intern immer mit der echten ID arbeitet. Wird das übersehen, entstehen verwaiste Redemption-Datensätze, die keiner gültigen Quote mehr zugeordnet werden können.
<?php
declare(strict_types=1);
namespace Mironsoft\GiftCard\Model\Redemption;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\LocalizedException;
use Magento\Quote\Api\Data\CartInterface;
use Mironsoft\GiftCard\Api\Data\GiftCardAccountInterface;
use Mironsoft\GiftCard\Api\GiftCardAccountRepositoryInterface;
/**
* Applies one or more gift card codes to a cart, consuming partial balances.
*/
final class GiftCardApplier
{
/**
* @param GiftCardAccountRepositoryInterface $accountRepository Repository for gift card accounts
* @param RedemptionResourceInterface $redemptionResource Resource model persisting redemption rows
*/
public function __construct(
private readonly GiftCardAccountRepositoryInterface $accountRepository,
private readonly RedemptionResourceInterface $redemptionResource,
) {
}
/**
* Applies a code to the given cart, consuming only the required partial amount.
*
* @param CartInterface $cart The cart the code is being applied to
* @param string $code The gift card code entered by the customer
* @param float $remainingCartAmount The remaining amount of the cart still to be covered
* @return float The amount actually consumed from this code
* @throws LocalizedException If the code is invalid, expired or disabled
* @throws CouldNotSaveException If the redemption row could not be persisted
*/
public function apply(CartInterface $cart, string $code, float $remainingCartAmount): float
{
$account = $this->accountRepository->getActiveByCode($code);
$this->assertRedeemable($account);
$amountToConsume = min($account->getBalance(), $remainingCartAmount);
$this->redemptionResource->recordPartialRedemption(
(int) $account->getId(),
(int) $cart->getId(),
$amountToConsume,
);
$account->setBalance($account->getBalance() - $amountToConsume);
if ($account->getBalance() <= 0.0001) {
$account->setStatus(GiftCardAccountInterface::STATUS_REDEEMED);
}
$this->accountRepository->save($account);
return $amountToConsume;
}
/**
* Validates that the account is active, not expired and not already fully redeemed.
*
* @param GiftCardAccountInterface $account The account to validate
* @return void
* @throws LocalizedException If the account cannot be redeemed in its current state
*/
private function assertRedeemable(GiftCardAccountInterface $account): void
{
if ($account->getStatus() !== GiftCardAccountInterface::STATUS_ACTIVE) {
throw new LocalizedException(__('This gift card code is not active.'));
}
if ($account->getBalance() <= 0.0) {
throw new LocalizedException(__('This gift card code has no remaining balance.'));
}
}
}
6. Admin-Workflow: manuelle Erstellung und Batch-Generierung
Für den Tagesbetrieb braucht das Admin-Panel zwei unterschiedliche Wege, Codes zu erzeugen. Der erste ist die manuelle Einzelerstellung, etwa wenn ein Support-Mitarbeiter als Kulanz einen Gift-Card-Code mit einem bestimmten Betrag ausstellen soll. Dieser Weg läuft über ein einfaches Admin-Formular, das direkt den SecureCodeGenerator aus Abschnitt 3 aufruft und den erzeugten Code sofort im Grid anzeigt, mit klarer Kennzeichnung, dass der Code nur einmalig sichtbar ist und danach maskiert wird.
Der zweite Weg ist die Batch-Generierung für Marketingkampagnen, bei der hunderte oder tausende Codes auf einmal erzeugt werden müssen, etwa für den Versand an Newsletter-Abonnenten. Dafür eignet sich die synchrone Admin-Oberfläche nicht, ein CLI-Command über den Mark-Shust-Wrapper bin/magento ist hier der richtige Ansatz. Der Command nimmt Parameter für Stückzahl, Betrag und optionales Ablaufdatum entgegen und arbeitet die Generierung in Batches ab, um Datenbank-Locks bei großen Stückzahlen zu vermeiden.
<?php
declare(strict_types=1);
namespace Mironsoft\GiftCard\Console\Command;
use Mironsoft\GiftCard\Model\Code\SecureCodeGenerator;
use Mironsoft\GiftCard\Api\GiftCardAccountRepositoryInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* CLI command for batch-generating gift card codes for marketing campaigns.
*/
final class GenerateBatchCommand extends Command
{
/** @var int Number of accounts persisted per database batch */
private const int BATCH_SIZE = 200;
/**
* @param SecureCodeGenerator $codeGenerator Generator producing secure, checksummed codes
* @param GiftCardAccountRepositoryInterface $accountRepository Repository persisting new accounts
*/
public function __construct(
private readonly SecureCodeGenerator $codeGenerator,
private readonly GiftCardAccountRepositoryInterface $accountRepository,
?string $name = null,
) {
parent::__construct($name);
}
/**
* Configures command name, description, arguments and options.
*
* @return void
*/
protected function configure(): void
{
$this->setName('mironsoft:giftcard:generate-batch')
->setDescription('Generates a batch of gift card codes for a marketing campaign')
->addArgument('quantity', InputArgument::REQUIRED, 'Number of codes to generate')
->addArgument('amount', InputArgument::REQUIRED, 'Initial balance per code')
->addOption('expires', null, InputOption::VALUE_OPTIONAL, 'Expiry date (Y-m-d)');
}
/**
* Executes the batch generation, persisting accounts in fixed-size chunks.
*
* @param InputInterface $input Console input containing quantity, amount and expiry
* @param OutputInterface $output Console output used for progress reporting
* @return int Exit code, 0 on success
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$quantity = (int) $input->getArgument('quantity');
$amount = (float) $input->getArgument('amount');
$expiresAt = $input->getOption('expires');
for ($generated = 0; $generated < $quantity; $generated += self::BATCH_SIZE) {
$batchCount = min(self::BATCH_SIZE, $quantity - $generated);
$this->generateChunk($batchCount, $amount, $expiresAt);
$output->writeln(sprintf('Generated %d / %d codes', $generated + $batchCount, $quantity));
}
return Command::SUCCESS;
}
/**
* Generates and persists a single chunk of gift card accounts.
*
* @param int $count Number of codes to generate in this chunk
* @param float $amount Initial balance per code
* @param string|null $expiresAt Optional expiry date in Y-m-d format
* @return void
*/
private function generateChunk(int $count, float $amount, ?string $expiresAt): void
{
for ($i = 0; $i < $count; $i++) {
$code = $this->codeGenerator->generate();
$this->accountRepository->createNew($code, $amount, $expiresAt);
}
}
}
Beide Wege teilen sich denselben Generator und dieselbe Persistenzschicht, es gibt also keine zweite, abweichende Code-Erzeugungslogik im Admin. Das verhindert, dass für Batch-Kampagnen versehentlich ein schwächeres Zufallsverfahren verwendet wird, nur weil es "schneller" für große Stückzahlen erschien.
7. API-Absicherung für Headless- und GraphQL-Checkouts
Headless-Frontends lösen Gift-Card-Codes über eine GraphQL-Mutation ein, typischerweise etwas wie applyGiftCardToCart. Diese Mutation ist ein klassisches Ziel für Enumeration-Angriffe: Ohne Schutzmaßnahmen kann ein Angreifer automatisiert tausende Codes durchprobieren und aus den unterschiedlichen Fehlermeldungen (ungültiger Code vs. abgelaufener Code vs. bereits eingelöster Code) Informationen über gültige Codes ableiten. Die Antwort muss deshalb für alle Fehlerfälle eine einheitliche, generische Meldung liefern, unabhängig vom tatsächlichen internen Grund der Ablehnung.
Rate-Limiting muss auf zwei Ebenen greifen: pro authentifizierter Kunden-ID für eingeloggte Checkouts und pro IP-Adresse beziehungsweise Session-Token für Gastbestellungen, da sich Angreifer sonst einfach ausloggen und den Schutz umgehen. Ein Sliding-Window-Zähler in einem schnellen Cache-Backend (Redis über den Magento-Cache-Layer) reicht dafür meist aus, kombiniert mit einem exponentiell steigenden Backoff nach mehreren Fehlversuchen in kurzer Zeit.
type Mutation {
applyGiftCardToCart(input: ApplyGiftCardToCartInput!): ApplyGiftCardToCartOutput
@resolver(class: "Mironsoft\\GiftCard\\Model\\Resolver\\ApplyGiftCardToCart")
}
input ApplyGiftCardToCartInput {
cart_id: String!
gift_card_code: String!
}
type ApplyGiftCardToCartOutput {
cart: Cart!
}
<?php
declare(strict_types=1);
namespace Mironsoft\GiftCard\Model\Guard;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Exception\LocalizedException;
/**
* Rate-limits gift card redemption attempts to mitigate enumeration attacks.
*/
final class RedemptionRateLimiter
{
/** @var int Maximum allowed attempts within the sliding window */
private const int MAX_ATTEMPTS = 5;
/** @var int Sliding window size in seconds */
private const int WINDOW_SECONDS = 300;
/**
* @param CacheInterface $cache Fast cache backend used for the sliding-window counter
*/
public function __construct(
private readonly CacheInterface $cache,
) {
}
/**
* Asserts that the given identity has not exceeded the allowed redemption attempts.
*
* @param string $identity Customer ID or IP-derived identity used as the rate limit key
* @return void
* @throws LocalizedException If too many attempts occurred within the window
*/
public function assertNotRateLimited(string $identity): void
{
$cacheKey = 'giftcard_redeem_' . hash('sha256', $identity);
$attempts = (int) $this->cache->load($cacheKey);
if ($attempts >= self::MAX_ATTEMPTS) {
// Generic message: never reveal whether a code was correct or not
throw new LocalizedException(__('Too many attempts. Please try again later.'));
}
$this->cache->save((string) ($attempts + 1), $cacheKey, [], self::WINDOW_SECONDS);
}
/**
* Masks a gift card code for safe inclusion in logs and API error contexts.
*
* @param string $code The raw gift card code
* @return string The masked representation, e.g. "****-****-A93F"
*/
public function maskCode(string $code): string
{
return str_repeat('*', max(0, strlen($code) - 4)) . substr($code, -4);
}
}
Ebenso wichtig: Jede Logzeile und jede Fehlerantwort der API darf ausschließlich den maskierten Code enthalten, niemals den Klartext. Das gilt auch für Application-Performance-Monitoring-Tools, die Request-Payloads mitschneiden, ein oft übersehener Kanal, über den Klartext-Codes unbeabsichtigt nach außen dringen können.
8. Refund-Handling: Rückgabe eines mit Gift Card bezahlten Produkts
Wird eine Bestellung, die teilweise oder vollständig mit einer Gift Card bezahlt wurde, retourniert, muss das Refund-Handling entscheiden, wohin der Gift-Card-Anteil des Rückerstattungsbetrags fließt. Die gängige und aus Betrugsschutz-Sicht sicherste Lösung: Der Gift-Card-Anteil wird dem ursprünglichen Code gutgeschrieben, sofern dieser Code noch existiert und nicht deaktiviert wurde, statt einen komplett neuen Code auszustellen. Das verhindert, dass durch wiederholte Bestell-Retour-Zyklen unkontrolliert neue, potenziell nicht nachverfolgbare Codes in Umlauf kommen.
Technisch hängt sich diese Logik an die sales_creditmemo-Erstellung. Ein Observer oder Plugin auf dem Refund-Prozess liest aus der Redemption-Historie (der in Abschnitt 2 beschriebenen Zuordnungstabelle), welche Codes an dieser Bestellung beteiligt waren und mit welchem Betrag, und schreibt bei einer Rückerstattung den anteiligen Betrag zurück auf das jeweilige Guthaben. Bei einer Teilretoure wird der Rückerstattungsbetrag proportional auf die ursprünglich verwendeten Codes verteilt, nicht pauschal auf den zuletzt verwendeten Code gebucht.
Ein Sonderfall: Ist der ursprüngliche Code inzwischen bereits vollständig anderweitig verbraucht oder aus Sicherheitsgründen gesperrt worden, kann keine Gutschrift mehr auf ihn erfolgen. In diesem Fall greift als Fallback die Ausstellung eines neuen, mit dem entsprechenden Betrag versehenen Codes, mit einem klaren Audit-Trail-Vermerk, dass es sich um eine Refund-Ersatzausstellung handelt. Diese Fallback-Logik sollte niemals stillschweigend greifen, sondern immer im Admin-Grid nachvollziehbar protokolliert werden, damit Support und Buchhaltung den Vorgang später eindeutig zuordnen können.
9. Redemption-Strategien im Vergleich
Je nach Shop-Anforderung eignen sich unterschiedliche Redemption-Strategien für Gift Cards unterschiedlich gut. Die Wahl beeinflusst direkt die Komplexität der Implementierung, das erreichbare Sicherheitsniveau und das Kundenerlebnis im Checkout.
| Strategie | Komplexität | Sicherheit | Kundenerlebnis |
|---|---|---|---|
| Einzelcode-Einlösung | Niedrig | Einfach zu prüfen, klare Rate-Limits | Genügt bei kleinen Beträgen, aber unflexibel |
| Mehrfach-Codes pro Warenkorb | Hoch | Braucht eigene Zuordnungstabelle und Reihenfolgen-Logik | Hoch, kombiniert mehrere Karten frei |
| Teilbetrag mit Restguthaben | Mittel bis hoch | Race-Condition-Schutz bei parallelen Requests nötig | Hoch, kein verlorenes Guthaben |
| Vollständige Einlösung | Niedrig | Wenig Angriffsfläche, Code danach entwertet | Niedrig bei Guthaben-Überschuss |
In der Praxis setzen die meisten produktiven Magento-2-Shops auf eine Kombination aus Mehrfach-Codes und Teilbetrag-Verrechnung, da Kunden Gutscheine selten exakt passend zur Bestellsumme besitzen. Der zusätzliche Implementierungsaufwand für die Restguthaben-Logik zahlt sich über die Zeit durch weniger Support-Anfragen zu "verschwundenem" Guthaben aus, sofern die Race-Condition-Absicherung sauber umgesetzt ist.
10. Zusammenfassung
Eine solide Gift Card Implementierung in Magento 2 steht und fällt mit sauberer Trennung der Verantwortlichkeiten: Ein eigenes Datenmodell im giftcardaccount hält Code, Guthaben und Status getrennt von der Bestelllogik. Eine kryptographisch sichere Code-Generierung mit Checksum-Ziffer verhindert vorhersagbare oder tippfehleranfällige Codes. Die Checkout-Integration über einen eigenen Total Collector sorgt für korrekte Reihenfolge gegenüber Steuer und Rabatten, statt Preise nachträglich zu manipulieren.
Mehrfach-Codes mit Teilbetrag-Verrechnung erhöhen den Implementierungsaufwand deutlich, verbessern aber das Kundenerlebnis spürbar. API-Absicherung mit Rate-Limiting und Code-Maskierung ist bei Headless- und GraphQL-Checkouts nicht optional, sondern Grundvoraussetzung gegen Enumeration-Angriffe. Und beim Refund-Handling entscheidet eine klare Regel, ob Guthaben auf den ursprünglichen Code zurückfließt oder ein neuer Ersatzcode mit Audit-Trail ausgestellt wird, für Nachvollziehbarkeit in Support und Buchhaltung gleichermaßen.
Gift Cards in Magento 2 implementieren, das Wichtigste auf einen Blick
Datenmodell
giftcardaccount mit eigenem Code, Guthaben und Status, plus separate Redemption-Tabelle für die Historie. Immer über db_schema.xml modellieren.
Sichere Codes
random_bytes() statt vorhersagbarer Zufallsquellen, plus Checksum-Ziffer gegen Tippfehler und Rate-Limiting gegen Brute-Force.
Checkout-Total
Eigener AbstractTotal Collector über totals.xml, korrekt zwischen Steuer und Grand Total positioniert.
API & Refund
Generische Fehlermeldungen und Code-Maskierung gegen Enumeration. Refund-Guthaben zurück auf den Ursprungscode, wo möglich.
11. FAQ: Gift Cards in Magento 2
1Was ist der Unterschied zwischen Gift Cards und Store Credit?
2Wie wird die giftcardaccount-Tabelle modelliert?
3Wie werden Codes sicher generiert?
4Wie wird das Guthaben vom Grand Total abgezogen?
5Können mehrere Codes gleichzeitig eingelöst werden?
6Was passiert mit Restguthaben?
7Wie werden Codes in großen Mengen erzeugt?
8Wie wird die Einlösung bei GraphQL abgesichert?
9Was passiert bei einer Rückerstattung?
10Welche Redemption-Strategie ist empfehlenswert?
Mironsoft
Magento-2-Entwicklung, Checkout-Integrationen und Hyvä-Themes
Gift Cards zuverlässig und sicher in eurem Shop einführen?
Wir implementieren codebasierte Gift Cards in Magento 2, von der Datenbank über die Checkout-Total-Integration bis zur abgesicherten GraphQL-Mutation, inklusive Admin-Workflow für Batch-Kampagnen und sauberem Refund-Handling.
Gift-Card-Modul
Datenmodell, sichere Code-Generierung und Checkout-Total nach Hyvä-Standard
GraphQL-Absicherung
Rate-Limiting, Code-Maskierung und Schutz gegen Enumeration-Angriffe
Admin & Refund
CLI-Batch-Generierung für Kampagnen und regelkonformes Refund-Handling