How to customize the refund form?¶

Note

This cookbook describes customization of a feature available only with Sylius/RefundPlugin installed.

A refund form is the form in which, as an Administrator, you can specify the exact amounts of money that will be refunded to a Customer.

Why would you customize the refund form?¶

Refund Plugin provides a generic solution for refunding orders, it is enough for a basic refund but many shops need more custom functionalities. For example, one may need to add refund payments scheduling, as they may be paid once a month.

How to add a field to the refund form?¶

The refund form is a form used to create the Refund Payment, thus in order to add a field to this form, you need to first add it to the Refund Payment’s model.

Refunds are processed with such a flow: command -> handler -> event -> listener, and this flow we will also need to customize in order to process the data from the new field.

In this customization, we will be extending the refund form with a scheduledAt field, which might be used then for scheduling the payments in the payment gateway.

1. Add the custom field to the Refund Payment:

Extended refund payment should look like this:

<?php

declare(strict_types=1);

namespace App\Entity\Refund;

use Doctrine\ORM\Mapping as ORM;
use Sylius\RefundPlugin\Entity\RefundPayment as BaseRefundPayment;

/**
 * @ORM\Entity
 * @ORM\Table(name="sylius_refund_refund_payment")
 */
class RefundPayment extends BaseRefundPayment implements RefundPaymentInterface
{
    /**
     * @var \DateTimeInterface|null
     *
     * @ORM\Column(type="datetime", nullable="true", name="scheduled_at")
     */
    protected $scheduledAt;

    public function getScheduledAt(): ?\DateTimeInterface
    {
        return $this->scheduledAt;
    }

    public function setScheduledAt(\DateTimeInterface $scheduledAt): void
    {
        $this->scheduledAt = $scheduledAt;
    }
}

It should implement a new interface:

<?php

declare(strict_types=1);

namespace App\Entity\Refund;

use Sylius\RefundPlugin\Entity\RefundPaymentInterface as BaseRefundPaymentInterface;

interface RefundPaymentInterface extends BaseRefundPaymentInterface
{
    public function getScheduledAt(): ?\DateTimeInterface;

    public function setScheduledAt(\DateTimeInterface $date): void;
}

Remember to update resource configuration:

# config/packages/sylius_refund.yaml
sylius_resource:
    resources:
        sylius_refund.refund_payment:
            classes:
                model: App\Entity\Refund\RefundPayment
                interface: App\Entity\Refund\RefundPaymentInterface

And update the database:

php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate

2. Modify the refund form:

Once we have the new field on the Refund Payment, we will need to display its input on the refund form. We need to overwrite the template orderRefunds.html.twig from Refund Plugin. To achieve that copy the entire orderRefunds.html.twig to templates/bundles/SyliusRefundPlugin/orderRefunds.html.twig:

mkdir templates/bundles/SyliusRefundPlugin
cp vendor/sylius/refund-plugin/src/Resources/views/orderRefunds.html.twig templates/bundles/SyliusRefundPlugin

Then add:

<div class="field">
    <label for="scheduled-at">Scheduled at</label>
    <input type="date" name="sylius_scheduled_at" id="scheduled-at" />
</div>

3. Adjust the ``RefundUnits`` command:

We want the refund payments to be created with our extra scheduledAt date, therefore we need to provide this data in command, We will extend the RefundUnits command from Refund Plugin and add the new value:

<?php

declare(strict_types=1);

namespace App\Command;

use Sylius\RefundPlugin\Command\RefundUnits as BaseRefundUnits;

final class RefundUnits extends BaseRefundUnits
{
    /** @var \DateTimeInterface|null */
    private $scheduledAt;

    public function __construct(
        string $orderNumber,
        array $units,
        array $shipments,
        int $paymentMethodId,
        string $comment,
        ?\DateTimeInterface $scheduledAt
    ) {
        parent::__construct($orderNumber, $units, $shipments, $paymentMethodId, $comment);

        $this->scheduledAt = $scheduledAt;
    }

    public function getScheduledAt(): ?\DateTimeInterface
    {
        return $this->scheduledAt;
    }

    public function setScheduledAt(?\DateTimeInterface $scheduledAt): void
    {
        $this->scheduledAt = $scheduledAt;
    }
}

4. Update the ``RefundUnitsCommandCreator``:

The controller related to the refund form dispatches the RefundUnits command, and there is a service that creates a command from request, so we need to overwrite the Sylius\RefundPlugin\Creator\RefundUnitsCommandCreator:

<?php

declare(strict_types=1);

namespace App\Creator;

use App\Command\RefundUnits;
use Sylius\RefundPlugin\Command\RefundUnits as BaseRefundUnits;
use Sylius\RefundPlugin\Converter\RefundUnitsConverterInterface;
use Sylius\RefundPlugin\Creator\RefundUnitsCommandCreatorInterface;
use Sylius\RefundPlugin\Exception\InvalidRefundAmount;
use Sylius\RefundPlugin\Model\OrderItemUnitRefund;
use Sylius\RefundPlugin\Model\RefundType;
use Sylius\RefundPlugin\Model\ShipmentRefund;
use Symfony\Component\HttpFoundation\Request;
use Webmozart\Assert\Assert;

final class RefundUnitsCommandCreator implements RefundUnitsCommandCreatorInterface
{
    /** @var RefundUnitsConverterInterface */
    private $refundUnitsConverter;

    public function __construct(RefundUnitsConverterInterface $refundUnitsConverter)
    {
        $this->refundUnitsConverter = $refundUnitsConverter;
    }

    public function fromRequest(Request $request): BaseRefundUnits
    {
        Assert::true($request->attributes->has('orderNumber'), 'Refunded order number not provided');

        $units = $this->refundUnitsConverter->convert(
            $request->request->has('sylius_refund_units') ? $request->request->all()['sylius_refund_units'] : [],
            RefundType::orderItemUnit(),
            OrderItemUnitRefund::class
        );

        $shipments = $this->refundUnitsConverter->convert(
            $request->request->has('sylius_refund_shipments') ? $request->request->all()['sylius_refund_shipments'] : [],
            RefundType::shipment(),
            ShipmentRefund::class
        );

        if (count($units) === 0 && count($shipments) === 0) {
            throw InvalidRefundAmount::withValidationConstraint('sylius_refund.at_least_one_unit_should_be_selected_to_refund');
        }

        /** @var string $comment */
        $comment = $request->request->get('sylius_refund_comment', '');

        // here we need to return the new RefundUnits command, with new data
        return new RefundUnits(
            $request->attributes->get('orderNumber'),
            $units,
            $shipments,
            (int) $request->request->get('sylius_refund_payment_method'),
            $comment,
            new \DateTime($request->request->get('sylius_scheduled_at'))
        );
    }
}

And register the new service:

# config/services.yaml
Sylius\RefundPlugin\Creator\RefundUnitsCommandCreatorInterface:
    class: App\Creator\RefundUnitsCommandCreator
    arguments:
        - '@Sylius\RefundPlugin\Converter\RefundUnitsConverterInterface'

5. Modify the ``RefundUnitsHandler``:

Now, when we have a new command, we also need to overwrite the related command handler:

<?php

declare(strict_types=1);

namespace App\CommandHandler;

use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use App\Command\RefundUnits;
use App\Event\UnitsRefunded;
use Sylius\RefundPlugin\Refunder\RefunderInterface;
use Sylius\RefundPlugin\Validator\RefundUnitsCommandValidatorInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Webmozart\Assert\Assert;

final class RefundUnitsHandler
{
    /** @var RefunderInterface */
    private $orderUnitsRefunder;

    /** @var RefunderInterface */
    private $orderShipmentsRefunder;

    /** @var MessageBusInterface */
    private $eventBus;

    /** @var OrderRepositoryInterface */
    private $orderRepository;

    /** @var RefundUnitsCommandValidatorInterface */
    private $refundUnitsCommandValidator;

    public function __construct(
        RefunderInterface $orderUnitsRefunder,
        RefunderInterface $orderShipmentsRefunder,
        MessageBusInterface $eventBus,
        OrderRepositoryInterface $orderRepository,
        RefundUnitsCommandValidatorInterface $refundUnitsCommandValidator
    ) {
        $this->orderUnitsRefunder = $orderUnitsRefunder;
        $this->orderShipmentsRefunder = $orderShipmentsRefunder;
        $this->eventBus = $eventBus;
        $this->orderRepository = $orderRepository;
        $this->refundUnitsCommandValidator = $refundUnitsCommandValidator;
    }

    public function __invoke(RefundUnits $command): void
    {
        $this->refundUnitsCommandValidator->validate($command);

        $orderNumber = $command->orderNumber();

        /** @var OrderInterface $order */
        $order = $this->orderRepository->findOneByNumber($orderNumber);

        $refundedTotal = 0;
        $refundedTotal += $this->orderUnitsRefunder->refundFromOrder($command->units(), $orderNumber);
        $refundedTotal += $this->orderShipmentsRefunder->refundFromOrder($command->shipments(), $orderNumber);

        /** @var string|null $currencyCode */
        $currencyCode = $order->getCurrencyCode();
        Assert::notNull($currencyCode);

        $this->eventBus->dispatch(new UnitsRefunded(
            $orderNumber,
            $command->units(),
            $command->shipments(),
            $command->paymentMethodId(),
            $refundedTotal,
            $currencyCode,
            $command->comment(),
            $command->getScheduledAt()
        ));
    }
}

And register it:

# config/services.yaml
Sylius\RefundPlugin\CommandHandler\RefundUnitsHandler:
    class: App\CommandHandler\RefundUnitsHandler
    arguments:
        - '@Sylius\RefundPlugin\Refunder\OrderItemUnitsRefunder'
        - '@Sylius\RefundPlugin\Refunder\OrderShipmentsRefunder'
        - '@sylius.event_bus'
        - '@sylius.repository.order'
        - '@Sylius\RefundPlugin\Validator\RefundUnitsCommandValidatorInterface'
    tags:
        - { name: messenger.message_handler, bus: sylius.command_bus }

6. Modify the ``UnitsReturned`` event:

In previous command handler we are dispatching a new event so now we need to create this event and related event handler:

event:

<?php

declare(strict_types=1);

namespace App\Event;

use Sylius\RefundPlugin\Event\UnitsRefunded as BaseUnitsRefunded;

final class UnitsRefunded extends BaseUnitsRefunded
{
    /** @var \DateTimeInterface */
    protected $scheduledAt;

    public function __construct(
        string $orderNumber,
        array $units,
        array $shipments,
        int $paymentMethodId,
        int $amount,
        string $currencyCode,
        string $comment,
        \DateTime $scheduledAt
    ) {
        parent::__construct($orderNumber, $units, $shipments, $paymentMethodId, $amount, $currencyCode, $comment);

        $this->scheduledAt = $scheduledAt;
    }

    public function getScheduledAt(): \DateTimeInterface
    {
        return $this->scheduledAt;
    }
}

And process manager to handle the new event:

<?php

declare(strict_types=1);

namespace App\ProcessManager;

use App\Entity\Refund\RefundPaymentInterface as AppRefundPaymentInterface;
use Doctrine\ORM\EntityManagerInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Sylius\Component\Core\Repository\PaymentMethodRepositoryInterface;
use Sylius\RefundPlugin\Entity\RefundPaymentInterface;
use Sylius\RefundPlugin\Event\RefundPaymentGenerated;
use Sylius\RefundPlugin\Event\UnitsRefunded;
use Sylius\RefundPlugin\Factory\RefundPaymentFactoryInterface;
use Sylius\RefundPlugin\ProcessManager\UnitsRefundedProcessStepInterface;
use Sylius\RefundPlugin\Provider\RelatedPaymentIdProviderInterface;
use Sylius\RefundPlugin\StateResolver\OrderFullyRefundedStateResolverInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Webmozart\Assert\Assert;

final class RefundPaymentProcessManager implements UnitsRefundedProcessStepInterface
{
    /** @var OrderFullyRefundedStateResolverInterface */
    private $orderFullyRefundedStateResolver;

    /** @var RelatedPaymentIdProviderInterface */
    private $relatedPaymentIdProvider;

    /** @var RefundPaymentFactoryInterface */
    private $refundPaymentFactory;

    /** @var OrderRepositoryInterface */
    private $orderRepository;

    /** @var PaymentMethodRepositoryInterface */
    private $paymentMethodRepository;

    /** @var EntityManagerInterface */
    private $entityManager;

    /** @var MessageBusInterface */
    private $eventBus;

    public function __construct(
        OrderFullyRefundedStateResolverInterface $orderFullyRefundedStateResolver,
        RelatedPaymentIdProviderInterface $relatedPaymentIdProvider,
        RefundPaymentFactoryInterface $refundPaymentFactory,
        OrderRepositoryInterface $orderRepository,
        PaymentMethodRepositoryInterface $paymentMethodRepository,
        EntityManagerInterface $entityManager,
        MessageBusInterface $eventBus
    ) {
        $this->orderFullyRefundedStateResolver = $orderFullyRefundedStateResolver;
        $this->relatedPaymentIdProvider = $relatedPaymentIdProvider;
        $this->refundPaymentFactory = $refundPaymentFactory;
        $this->orderRepository = $orderRepository;
        $this->paymentMethodRepository = $paymentMethodRepository;
        $this->entityManager = $entityManager;
        $this->eventBus = $eventBus;
    }

    public function next(UnitsRefunded $unitsRefunded): void
    {
        /** @var OrderInterface|null $order */
        $order = $this->orderRepository->findOneByNumber($unitsRefunded->orderNumber());
        Assert::notNull($order);

        /** @var PaymentMethodInterface|null $paymentMethod */
        $paymentMethod = $this->paymentMethodRepository->find($unitsRefunded->paymentMethodId());
        Assert::notNull($paymentMethod);

        /** @var AppRefundPaymentInterface $refundPayment */
        $refundPayment = $this->refundPaymentFactory->createWithData(
            $order,
            $unitsRefunded->amount(),
            $unitsRefunded->currencyCode(),
            RefundPaymentInterface::STATE_NEW,
            $paymentMethod
        );
        $refundPayment->setScheduledAt($unitsRefunded->getScheduledAt());

        $this->entityManager->persist($refundPayment);
        $this->entityManager->flush();

        $this->eventBus->dispatch(new RefundPaymentGenerated(
            $refundPayment->getId(),
            $unitsRefunded->orderNumber(),
            $unitsRefunded->amount(),
            $unitsRefunded->currencyCode(),
            $unitsRefunded->paymentMethodId(),
            $this->relatedPaymentIdProvider->getForRefundPayment($refundPayment)
        ));

        $this->orderFullyRefundedStateResolver->resolve($unitsRefunded->orderNumber());
    }
}

And register it:

Sylius\RefundPlugin\ProcessManager\RefundPaymentProcessManager:
    class: App\ProcessManager\RefundPaymentProcessManager
    arguments:
        - '@Sylius\RefundPlugin\StateResolver\OrderFullyRefundedStateResolverInterface'
        - '@Sylius\RefundPlugin\Provider\RelatedPaymentIdProviderInterface'
        - '@sylius_refund.factory.refund_payment'
        - '@sylius.repository.order'
        - '@sylius.repository.payment_method'
        - '@doctrine.orm.default_entity_manager'
        - '@sylius.event_bus'
    tags:
        - {name: sylius_refund.units_refunded.process_step, priority: 50}

7. Display the new field on the refund payment:

And as the last step, we need to overwrite the template _refundPayments.html.twig from Refund Plugin. Copy the entire _refundPayments.html.twig to templates/bundles/SyliusRefundPlugin/Order/Admin/_refundPayments.html.twig:

mkdir -p templates/bundles/SyliusRefundPlugin/Order/Admin
cp vendor/sylius/refund-plugin/src/Resources/views/Order/Admin/_refundPayments.html.twig templates/bundles/SyliusRefundPlugin/Order/Admin/

And replace header with:

<div class="header">
    {{ refund_payment.paymentMethod  }} {%  if refund_payment.scheduledAt is not null %} (Payment should be made in {{ refund_payment.scheduledAt|date('Y-M-d') }}) {% endif %}
</div>

And that’s it, we have a new field on Refund Payment with a “scheduled at” date (when admin/payment gateway should make the payment), in your application, you probably will add crone to automatize it.