How the payment gateway command pattern cleanly separates online and offline refunds and correctly models partial refunds across split shipments
Anyone building a custom payment method for Magento 2 pours most of their care into checkout, authorization, and capture, because that is where revenue is created. Refund handling is often treated as a minor afterthought and bolted on later as a simple API call, without really engaging with the command pattern that underpins Magento's payment gateway architecture. That neglect catches up eventually: misbooked credit memos, duplicate refunds, or partial refunds that do not line up with the right shipment are common in payment integrations that grew organically. This article shows how to structure refund handling correctly along the command pattern, including partial refunds and robust error handling.
Table of Contents
- 1. Why refund handling for custom payment methods gets underrated
- 2. Placing the refund command within the payment gateway command pattern
- 3. Implementing an online refund: a custom command that calls the provider
- 4. Offline versus online refund: knowing which path applies
- 5. Correctly modeling partial refunds across split shipments
- 6. Error handling when the payment provider declines the refund
- 7. Idempotency: reliably avoiding duplicate refunds
- 8. A testing strategy for refund workflows
- 9. Refund strategies at a glance
- 10. Summary
- 11. FAQ
1. Why refund handling for custom payment methods gets underrated
Most custom payment integrations are built under time pressure ahead of a launch, and focus lands almost entirely on the path that directly generates revenue: checkout, authorization, capture. The reverse path, the refund, is often only tackled once the first real return case hits production, usually under even more time pressure than the original checkout flow.
That prioritization is understandable, but risky, because Magento's payment gateway architecture deliberately routes refunds through the same command pattern as authorization and capture. Anyone who ignores that structure for refunds and bolts on an isolated, quickly written API call loses exactly the consistency guarantees the framework provides, such as clean transaction chaining, response validation, and unified error handling across every payment operation.
2. Placing the refund command within the payment gateway command pattern
Magento's payment gateway architecture bundles each payment operation into its own command, registered through a CommandPoolInterface configuration in di.xml. Each command typically combines a request builder, a transfer factory, a client, and a response validator, optionally topped off with a handler that writes the result back onto the payment object. Refunds follow the exact same structure, except the request builder references the original capture transaction instead of card data.
The refund command fires when an admin user triggers a refund through a credit memo: the order payment internally calls the matching gateway chain through the registered command name refund. Whether the refund button even appears in the admin UI is controlled by the can_refund and can_refund_partial_per_invoice feature flags in the method configuration, which determine whether automated online refunds are possible at all, or only the offline path is available.
<!-- 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. Implementing an online refund: a custom command that calls the provider
The online refund command first reads the original capture transaction ID off the payment object, usually via getParentTransactionId() or from the additional_information data where the payment method stores its own references. The request builder turns that into a payload with amount, currency, and reference ID, which the transfer factory converts into a format the client understands before the client performs the actual HTTP call against the payment provider's API.
After the call, a ValidatorInterface implementation checks whether the response actually signals a successful refund, before any handler updates the payment object. Only once that validation passes does the handler write back the new refund transaction ID and the refunded amount, so later partial refunds can rely on a correct history. That strict ordering, validate first, then persist, is the central difference from a naively written refund call.
<?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;
/**
* Writes the payment provider's refund transaction ID back onto the payment.
*/
final class RefundHandler implements HandlerInterface
{
/**
* Processes the validated refund response and updates the 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: knowing which path applies
Not every refund should or can run automatically through the payment provider's API. If cash was handed back in a store, or a manual bank transfer already happened outside Magento, the offline refund is the correct path: a credit memo gets booked without any API call, because the actual money movement already took place. The admin UI offers the Refund Offline checkbox for exactly this, available regardless of the can_refund flag.
When can_refund is set to false in the method configuration, Magento consistently only offers the offline path, because without that flag no online refund command may even be registered. This distinction matters for the implementation itself: an online refund command should only be built for payment providers that expose a genuine refund API, rather than pretending an offline process could somehow be automated.
5. Correctly modeling partial refunds across split shipments
Orders with multiple partial shipments also generate multiple invoices, and each one carries its own refundable remainder. Magento's CreditmemoFactory::createByInvoice() binds a credit memo to a single invoice, preventing a partial refund from accidentally exceeding what was actually paid for that particular shipment. A custom refund command has to respect that binding and must not validate the amount against the order's grand total instead.
Things get trickier when the payment provider only allows a single refund per original transaction ID, for instance because one capture transaction covers several invoices. In that case Magento's own invoice binding is not sufficient on its own, and the custom integration needs an additional mapping table that tracks which portion of which invoice has already been refunded against which provider transaction, so cumulative partial refunds can be validated against the remaining headroom of the original payment.
<?php
declare(strict_types=1);
// Validation: a partial refund must not exceed the referenced invoice's remainder
$invoiceRefundable = (float) $invoice->getGrandTotal() - (float) $invoice->getTotalRefunded();
if ($requestedAmount > $invoiceRefundable + 0.0001) {
throw new LocalizedException(
__('The requested amount exceeds the refundable remainder of this shipment.')
);
}
6. Error handling when the payment provider declines the refund
If the payment provider declines a refund, for instance because the merchant account balance is insufficient or the original transaction is too old, the custom command must throw a CommandException at exactly that point, before any credit memo gets persisted. Magento's refund flow is built so a credit memo is only saved once the command completes without an exception, meaning a properly thrown exception prevents accounting records and actual cash flow from drifting apart.
It also matters that the error message shown to the admin user is specific enough to distinguish a recoverable problem from a permanent one, rather than surfacing a generic gateway error. The full raw response from the payment provider should additionally go into the log, so support can later trace exactly why a refund failed without having to query the provider again.
<?php
declare(strict_types=1);
use Magento\Payment\Gateway\Command\CommandException;
try {
$response = $this->client->placeRequest($transferObject);
} catch (PsrGatewayHttpException $exception) {
$this->logger->error('Refund declined', ['response' => $exception->getRawResponse()]);
throw new CommandException(
__('The payment provider declined the refund: %1', $exception->getReasonCode())
);
}
7. Idempotency: reliably avoiding duplicate refunds
A double click on the admin refund button, a repeatedly executed background job, or a network timeout followed by a retry can all accidentally trigger the same refund twice. The most reliable safeguard is an idempotency key sent with every refund call, which the payment provider itself checks against already processed requests, so a repeated request returns the same refund instead of executing it again.
The custom integration should additionally check locally whether the amount already refunded for an invoice, combined with the newly requested amount, would exceed the refundable remainder, before triggering any command at all. That check belongs inside a database transaction with a row lock on the affected record, so concurrent requests, for example from two browser tabs of the same admin user, cannot reserve the same remainder twice at once.
8. A testing strategy for refund workflows
Unit tests should exercise the request builder and validation logic in isolation against a mocked client, so edge cases such as a declined refund or an unexpected response structure can be reproduced without a real network call. Integration tests against the payment provider's sandbox environment round this out, covering the full path from credit memo to actual API response.
Because financial regressions are considerably more expensive than typical feature bugs, it pays off to keep a fixed set of test cases that runs automatically before every deployment touching the payment module: a full refund, a partial refund across multiple invoices, a declined refund, and an attempt to refund an amount that has already been fully refunded. In practice, those four cases cover the vast majority of real error scenarios.
9. Refund strategies at a glance
The table below summarizes typical refund scenarios along with the responsible component and the most important caveat for each.
| Scenario | Refund Type | Responsible Component | Caveat |
|---|---|---|---|
| Full refund before shipment | Online | Refund command incl. handler | API call against the original capture transaction |
| Partial refund after split shipment | Online | CreditmemoFactory + invoice_id | Amount must not exceed the invoice's remainder |
| Cash returned in store | Offline | Credit memo only, no API call | Purely bookkeeping, no contact with the provider |
| Provider declines the refund | Online, failed | CommandException | Credit memo must not be persisted |
| Repeated click on refund | Online, potentially duplicate | Idempotency key + local check | Must be deduplicated by the payment provider |
Mironsoft
Magento development, module consulting, and system architecture
A Magento project that needs a second opinion or experienced execution?
We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.
Architecture Consulting
Have module and system architecture thought through properly before you build.
Custom Module Development
Build custom Magento modules cleanly, following best practices.
Code Review & Audit
Have existing modules reviewed for performance, security, and maintainability.
10. Summary
Refund Handling: The Essentials at a Glance
Core idea
Refund handling follows the same command pattern as authorization and capture, not an isolated API call.
Key distinction
An online refund with a real provider call versus an offline refund as pure bookkeeping without API contact.
Biggest risk
A credit memo that gets persisted despite a declined or duplicate refund.
Success criterion
Partial refunds stay bound exactly to their invoice and never exceed its remainder.