Refund-Handling für eigene Zahlungsmethoden korrekt implementieren
AI generated
M2
di.xml
Magento 2 · Payment · Refund-Handling
Refund-Handling für eigene Zahlungsmethoden korrekt implementieren
Wie das Payment-Gateway-Command-Pattern Online- und Offline-Refunds sauber trennt und Teilrückerstattungen bei Teillieferungen zuverlässig abbildet

Wer eine eigene Zahlungsmethode für Magento 2 entwickelt, investiert die meiste Sorgfalt in Checkout, Autorisierung und Capture, weil dort der Umsatz entsteht. Das Refund-Handling wird dagegen oft als kleine Nebenaufgabe behandelt und nachträglich mit einem einfachen API-Aufruf nachgerüstet, ohne das eigentliche Command-Pattern von Magentos Payment-Gateway-Architektur wirklich zu durchdringen. Genau das rächt sich später: falsch verbuchte Creditmemos, doppelte Rückerstattungen oder Teilrefunds, die nicht zur richtigen Lieferung passen, sind in gewachsenen Zahlungsintegrationen keine Seltenheit. Dieser Artikel zeigt, wie sich Refund-Handling entlang des Command-Pattern korrekt strukturieren lässt, inklusive Teilrückerstattungen und robuster Fehlerbehandlung.

11 Min. Lesezeit Payment Gateway Refund Command Pattern Creditmemo

1. Warum Refund-Handling bei eigenen Zahlungsmethoden unterschätzt wird

Die meisten Custom-Payment-Integrationen entstehen unter Zeitdruck vor einem Launch, und der Fokus liegt fast ausschließlich auf dem Zahlungsweg, der direkt Umsatz erzeugt: Checkout, Autorisierung, Capture. Der Rückweg, also die Rückerstattung, wird häufig erst in Angriff genommen, wenn der erste reale Retourenfall im Livebetrieb ansteht, und dann meist unter noch größerem Zeitdruck als der ursprüngliche Checkout-Flow.

Diese Priorisierung ist nachvollziehbar, aber riskant, weil Magentos Payment-Gateway-Architektur den Refund-Pfad bewusst über dasselbe Command-Pattern abbildet wie Autorisierung und Capture. Wer diesen Aufbau beim Refund ignoriert und stattdessen einen isolierten, schnell zusammengeschriebenen API-Aufruf einbaut, verliert genau die Konsistenzgarantien, die das Framework eigentlich mitliefert, etwa saubere Transaktionsverkettung, Validierung der Rückgabewerte und eine einheitliche Fehlerbehandlung über alle Zahlungsoperationen hinweg.

2. Das Payment-Gateway-Command-Pattern und die Rolle des Refund-Commands einordnen

Magentos Payment-Gateway-Architektur bündelt jede Zahlungsoperation in einem eigenen Command, registriert über eine CommandPoolInterface-Konfiguration in der di.xml. Jeder Command setzt sich typischerweise aus einem Request-Builder, einer Transfer-Factory, einem Client und einem Response-Validator zusammen, optional ergänzt um einen Handler, der das Ergebnis auf das Payment-Objekt zurückschreibt. Für Refunds gilt exakt dieselbe Struktur, nur dass der Request-Builder statt Kartendaten die ursprüngliche Capture-Transaktion referenziert.

Ausgelöst wird der Refund-Command, sobald ein Admin-Nutzer über ein Creditmemo eine Rückerstattung anstößt: Der Order-Payment ruft intern die passende Gateway-Kette über den registrierten Command-Namen refund auf. Ob der Refund-Button in der Admin-Oberfläche überhaupt erscheint, steuern die Feature-Flags can_refund und can_refund_partial_per_invoice in der Methoden-Konfiguration, die exakt festlegen, ob überhaupt automatisierte Online-Refunds möglich sind oder nur der Offline-Pfad zur Verfügung steht.


<!-- app/code/Mironsoft/CustomPayment/etc/di.xml -->
<virtualType name="MironsoftCustomPaymentCommandPool" type="Magento\Payment\Gateway\Command\CommandPool">
    <arguments>
        <argument name="commands" xsi:type="array">
            <item name="authorize" xsi:type="string">MironsoftCustomPaymentAuthorizeCommand</item>
            <item name="capture" xsi:type="string">MironsoftCustomPaymentCaptureCommand</item>
            <item name="refund" xsi:type="string">MironsoftCustomPaymentRefundCommand</item>
            <item name="void" xsi:type="string">MironsoftCustomPaymentVoidCommand</item>
        </argument>
    </arguments>
</virtualType>

3. Online-Refund implementieren: eigener Command mit Aufruf an den Zahlungsanbieter

Der Online-Refund-Command liest zunächst die ursprüngliche Capture-Transaktions-ID aus dem Payment-Objekt, meist über getParentTransactionId() oder aus den additional_information-Daten, in denen die Zahlungsmethode eigene Referenzen ablegt. Der Request-Builder formt daraus einen Payload mit Betrag, Währung und Referenz-ID, den die Transfer-Factory in ein für den Client verständliches Format überführt, bevor der Client den eigentlichen HTTP-Aufruf gegen die API des Zahlungsanbieters ausführt.

Nach dem Aufruf prüft ein ValidatorInterface-Implementierung, ob die Antwort einen erfolgreichen Refund signalisiert, bevor überhaupt ein Handler das Payment-Objekt aktualisiert. Erst wenn diese Validierung positiv ausfällt, schreibt der Handler die neue Refund-Transaktions-ID sowie den erstatteten Betrag zurück, sodass spätere Teilrefunds auf eine korrekte Historie zurückgreifen können. Diese strikte Reihenfolge, erst validieren, dann persistieren, ist der zentrale Unterschied zu einem naiv geschriebenen Refund-Aufruf.


<?php
declare(strict_types=1);

namespace Mironsoft\CustomPayment\Gateway\Response;

use Magento\Payment\Gateway\Response\HandlerInterface;
use Magento\Payment\Gateway\Helper\ContextHelper;
use Magento\Payment\Gateway\Data\PaymentDataObjectInterface;

/**
 * Schreibt die Refund-Transaktions-ID des Zahlungsanbieters auf das Payment zurück.
 */
final class RefundHandler implements HandlerInterface
{
    /**
     * Verarbeitet die validierte Refund-Antwort und aktualisiert das Payment.
     *
     * @param array $handlingSubject
     * @param array $response
     * @return void
     */
    public function handle(array $handlingSubject, array $response): void
    {
        $paymentDO = SubjectReader::readPayment($handlingSubject);
        $payment = $paymentDO->getPayment();
        ContextHelper::assertOrderPayment($payment);

        $payment->setTransactionId($response['refund_id']);
        $payment->setIsTransactionClosed(true);
        $payment->setShouldCloseParentTransaction(false);
    }
}

4. Offline- versus Online-Refund: wann welcher Weg sinnvoll ist

Nicht jede Rückerstattung soll oder darf automatisiert über die API des Zahlungsanbieters laufen. Wurde Bargeld im Laden zurückgegeben oder eine manuelle Banküberweisung außerhalb von Magento bereits ausgeführt, ist der Offline-Refund der richtige Weg: Ein Creditmemo wird gebucht, ohne dass ein API-Aufruf stattfindet, weil die eigentliche Geldbewegung längst passiert ist. Die Admin-Oberfläche bietet dafür die Checkbox Refund Offline, die unabhängig von can_refund immer verfügbar ist.

Steht can_refund in der Methoden-Konfiguration auf false, bietet Magento konsequent nur den Offline-Pfad an, weil ohne diesen Flag kein Online-Refund-Command registriert werden darf. Diese Unterscheidung ist wichtig für die eigene Implementierung: Ein Online-Refund-Command sollte nur für Zahlungsmethoden gebaut werden, deren Anbieter eine echte Refund-API anbietet, statt vorzugaukeln, dass ein Offline-Vorgang technisch automatisierbar wäre.

5. Teilrückerstattungen bei Teillieferungen korrekt abbilden

Bei Bestellungen mit mehreren Teillieferungen entstehen auch mehrere Invoices, und jede davon hat einen eigenen erstattbaren Restbetrag. Magentos CreditmemoFactory::createByInvoice() bindet ein Creditmemo gezielt an eine einzelne Invoice und verhindert dadurch, dass ein Teilrefund versehentlich mehr erstattet, als für diese konkrete Lieferung tatsächlich bezahlt wurde. Der eigene Refund-Command muss diese Zuordnung respektieren und darf den Betrag nicht pauschal gegen die Gesamtbestellsumme validieren.

Komplizierter wird es, wenn der Zahlungsanbieter selbst nur einen Refund pro ursprünglicher Transaktions-ID zulässt, etwa weil eine einzelne Capture-Transaktion mehrere Invoices abdeckt. In diesem Fall reicht die reine Invoice-Zuordnung von Magento nicht aus, und die eigene Integration braucht eine zusätzliche Zuordnungstabelle, die festhält, welcher Teilbetrag welcher Invoice bereits gegen welche PSP-Transaktion erstattet wurde, damit sich kumulierte Teilrefunds korrekt gegen den verbleibenden Spielraum der ursprünglichen Zahlung prüfen lassen.


<?php
declare(strict_types=1);

// Validierung: Teilrefund darf den Restbetrag der referenzierten Invoice nicht überschreiten
$invoiceRefundable = (float) $invoice->getGrandTotal() - (float) $invoice->getTotalRefunded();

if ($requestedAmount > $invoiceRefundable + 0.0001) {
    throw new LocalizedException(
        __('Der angeforderte Betrag übersteigt den erstattbaren Rest dieser Lieferung.')
    );
}

6. Fehlerbehandlung, wenn der Zahlungsanbieter den Refund ablehnt

Lehnt der Zahlungsanbieter einen Refund ab, etwa weil das Guthaben des Händlerkontos nicht ausreicht oder die ursprüngliche Transaktion bereits zu lange zurückliegt, muss der eigene Command genau an dieser Stelle eine CommandException werfen, bevor überhaupt ein Creditmemo persistiert wird. Magentos Refund-Ablauf ist so gebaut, dass ein Creditmemo nur gespeichert wird, wenn der Command ohne Exception durchläuft, sodass eine sauber geworfene Exception verhindert, dass Buchhaltungsdaten und tatsächlicher Geldfluss auseinanderlaufen.

Wichtig ist außerdem, dass die dem Admin-Nutzer angezeigte Fehlermeldung konkret genug ist, um zwischen einem behebbaren Problem und einem endgültigen Fehler zu unterscheiden, statt nur eine generische Gateway-Fehlermeldung auszugeben. Die vollständige Rohantwort des Zahlungsanbieters gehört zusätzlich ins Log, damit der Support später nachvollziehen kann, warum genau ein Refund gescheitert ist, ohne den Vorgang erneut beim Anbieter anfragen zu müssen.


<?php
declare(strict_types=1);

use Magento\Payment\Gateway\Command\CommandException;

try {
    $response = $this->client->placeRequest($transferObject);
} catch (PsrGatewayHttpException $exception) {
    $this->logger->error('Refund abgelehnt', ['response' => $exception->getRawResponse()]);

    throw new CommandException(
        __('Der Zahlungsanbieter hat den Refund abgelehnt: %1', $exception->getReasonCode())
    );
}

7. Idempotenz: doppelte Refunds zuverlässig vermeiden

Ein doppelter Klick auf den Refund-Button im Admin, ein wiederholt ausgeführter Hintergrundjob oder ein Netzwerk-Timeout mit anschließendem Retry können denselben Refund versehentlich zweimal auslösen. Die zuverlässigste Absicherung ist ein Idempotency-Key, der bei jedem Refund-Aufruf mitgeschickt wird und den der Zahlungsanbieter selbst gegen bereits verarbeitete Anfragen abgleicht, sodass eine wiederholte Anfrage denselben Refund zurückliefert, statt ihn erneut auszuführen.

Zusätzlich sollte die eigene Integration lokal prüfen, ob der bereits erstattete Betrag einer Invoice zusammen mit dem neu angeforderten Betrag den erstattbaren Rest übersteigt, bevor überhaupt ein Command ausgelöst wird. Diese Prüfung gehört in eine Datenbanktransaktion mit Zeilensperre auf den betroffenen Datensatz, damit auch parallel laufende Anfragen, etwa aus zwei Browser-Tabs desselben Admin-Nutzers, nicht gleichzeitig denselben Restbetrag doppelt reservieren können.

8. Testing-Strategie für Refund-Workflows

Unit-Tests sollten den Request-Builder und die Validierungslogik isoliert gegen einen gemockten Client prüfen, damit sich Randfälle wie ein abgelehnter Refund oder eine unerwartete API-Antwortstruktur ohne echten Netzwerkaufruf reproduzieren lassen. Ergänzend gehören Integrationstests gegen die Sandbox-Umgebung des Zahlungsanbieters dazu, die den kompletten Pfad vom Creditmemo bis zur tatsächlichen API-Antwort abdecken.

Weil finanzielle Regressionen deutlich teurer sind als typische Feature-Bugs, lohnt sich eine feste Sammlung von Testfällen, die vor jedem Deployment am Zahlungsmodul automatisiert durchläuft: vollständiger Refund, Teilrefund über mehrere Invoices, abgelehnter Refund sowie der Versuch, einen bereits vollständig erstatteten Betrag erneut zu erstatten. Diese vier Fälle decken in der Praxis die überwiegende Mehrheit realer Fehlerszenarien ab.

9. Refund-Strategien im Überblick

Die folgende Tabelle fasst typische Refund-Szenarien mit der jeweils zuständigen Komponente und der wichtigsten Besonderheit zusammen.

Szenario Refund-Typ Zuständige Komponente Besonderheit
Vollständiger Refund vor Versand Online Refund-Command inkl. Handler PSP-API-Aufruf gegen ursprüngliche Capture-Transaktion
Teilrefund nach Teillieferung Online CreditmemoFactory + invoice_id Betrag darf Restbetrag der Invoice nicht überschreiten
Rückgabe von Bargeld im Laden Offline Nur Creditmemo, kein API-Aufruf Rein buchhalterisch, kein Kontakt zum Zahlungsanbieter
PSP lehnt Refund ab Online, fehlgeschlagen CommandException Creditmemo darf nicht gespeichert werden
Wiederholter Klick auf Refund Online, potenziell doppelt Idempotency-Key + lokale Prüfung Muss vom Zahlungsanbieter dedupliziert werden

Mironsoft

Magento-Entwicklung, Modul-Beratung und Systemarchitektur

Magento-Projekt, das eine zweite Meinung oder erfahrene Umsetzung braucht?

Wir entwickeln individuelle Magento-Module, beraten bei Architekturentscheidungen und übernehmen komplexe Umsetzungen, von der Service-Contract-Planung bis zum produktionsreifen Deployment.

Architektur-Beratung

Modul- und Systemarchitektur vor der Umsetzung fundiert durchdenken lassen.

Custom-Modul-Entwicklung

Individuelle Magento-Module nach Best Practices sauber umsetzen.

Code-Review & Audit

Bestehende Module auf Performance, Sicherheit und Wartbarkeit prüfen lassen.

10. Zusammenfassung

Refund-Handling: Das Wichtigste auf einen Blick

Kernidee

Refund-Handling folgt demselben Command-Pattern wie Autorisierung und Capture, nicht einem isolierten API-Aufruf.

Wichtigste Unterscheidung

Online-Refund mit echtem PSP-Aufruf versus Offline-Refund als reine Buchung ohne API-Kontakt.

Größtes Risiko

Ein Creditmemo, das trotz abgelehntem oder doppeltem Refund persistiert wird.

Erfolgskriterium

Teilrefunds bleiben exakt an ihre Invoice gebunden und laufen niemals über deren Restbetrag hinaus.

11. FAQ: Refund-Handling: Das Wichtigste auf einen Blick

1Warum reicht ein einfacher API-Aufruf für Refunds nicht aus?
Weil Magentos Payment-Gateway-Architektur den Refund über dasselbe Command-Pattern wie Capture abbildet und dessen Validierungs- und Handler-Struktur sonst umgangen wird.
2Was steuert, ob ein Online-Refund im Admin überhaupt möglich ist?
Die Feature-Flags can_refund und can_refund_partial_per_invoice in der Methoden-Konfiguration.
3Wann ist ein Offline-Refund der richtige Weg?
Wenn die tatsächliche Geldbewegung bereits außerhalb von Magento stattgefunden hat, etwa bei Barrückgabe im Laden.
4Wie verhindert Magento, dass ein Teilrefund die Invoice überstrapaziert?
CreditmemoFactory::createByInvoice bindet das Creditmemo an eine konkrete Invoice und deren Restbetrag.
5Was passiert, wenn der Zahlungsanbieter den Refund ablehnt?
Der Command muss eine CommandException werfen, bevor ein Creditmemo persistiert wird, damit Buchhaltung und Geldfluss synchron bleiben.
6Wie lässt sich ein doppelter Refund zuverlässig vermeiden?
Über einen Idempotency-Key beim Zahlungsanbieter kombiniert mit einer lokalen Prüfung des Restbetrags unter Zeilensperre.
7Was gehört unbedingt ins Log bei einem fehlgeschlagenen Refund?
Die vollständige Rohantwort des Zahlungsanbieters, damit der Support den Fehler ohne erneute Anfrage nachvollziehen kann.
8Kann ein Refund-Command mehrere Invoices auf einmal erstatten?
Nur wenn die zugrunde liegende PSP-Transaktion das unterstützt, sonst braucht es eine eigene Zuordnungstabelle je Invoice.
9Welche Testfälle sind für ein Zahlungsmodul unverzichtbar?
Vollständiger Refund, Teilrefund über mehrere Invoices, abgelehnter Refund und wiederholter Refund auf einen bereits erstatteten Betrag.
10Warum sollte die Fehlermeldung im Admin nicht generisch sein?
Damit der Nutzer zwischen einem behebbaren Problem und einem endgültigen Fehler unterscheiden kann, statt blind erneut zu versuchen.