Avoid sending multiple invoice emails in Sylius
We run into an issue in a Sylius project where we've been using the Sylius InvoicingPlugin as well as the SyliusPayumStripePlugin plugin to handle Stripe payments. Invoice emails were sent out twice to the customer with both plugins active.
Debugging the issue was not easy, but as it turns out, somehow, the order complete
event is fired twice. This executes the logic in the Sylius\InvoicingPlugin\EventProducer\OrderPaymentPaidProducer
twice, sending out two invoice emails.
Since I could not figure out how or where to solve the initial issue, I decided to implement a work-a-round in the OrderPaymentPaidProducer
class. Since both emails were sent in the same request, I decided to track which order was already processed.
<?php
declare(strict_types=1);
namespace App\EventProducer;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\InvoicingPlugin\DateTimeProvider;
use Sylius\InvoicingPlugin\Doctrine\ORM\InvoiceRepositoryInterface;
use Sylius\InvoicingPlugin\Event\OrderPaymentPaid;
use Symfony\Component\Messenger\MessageBusInterface;
use Webmozart\Assert\Assert;
final class OrderPaymentPaidProducer
{
private MessageBusInterface $eventBus;
private DateTimeProvider $dateTimeProvider;
private InvoiceRepositoryInterface $invoiceRepository;
private array $processedOrderIdCache = [];
public function __construct(
MessageBusInterface $eventBus,
DateTimeProvider $dateTimeProvider,
InvoiceRepositoryInterface $invoiceRepository
) {
$this->eventBus = $eventBus;
$this->dateTimeProvider = $dateTimeProvider;
$this->invoiceRepository = $invoiceRepository;
}
public function __invoke(PaymentInterface $payment): void
{
if (!$this->shouldEventBeDispatched($payment)) {
return;
}
$order = $payment->getOrder();
Assert::notNull($order);
if ($this->orderAlreadyProcessedInCurrentRequest($order)) {
return;
}
$this->eventBus->dispatch(new OrderPaymentPaid(
$order->getNumber(),
$this->dateTimeProvider->__invoke()
));
}
private function shouldEventBeDispatched(PaymentInterface $payment): bool
{
/** @var OrderInterface $order */
$order = $payment->getOrder();
return null !== $order && null !== $this->invoiceRepository->findOneByOrder($order);
}
private function orderAlreadyProcessedInCurrentRequest(OrderInterface $order): bool
{
if (in_array($order->getNumber(), $this->processedOrderIdCache)) {
return true;
}
$this->processedOrderIdCache[] = $order->getNumber();
return false;
}
}
Since the original OrderPaymentPaidProducer
was made final, I had to copy it to the project's src/EventProducer
directory and customize it.
I added the function orderAlreadyProcessedInCurrentRequest()
to the class and call it in the __invoke()
method like this:
if ($this->orderAlreadyProcessedInCurrentRequest($order)) {
return;
}
The logic is simple: We keep track of all processed order numbers, and every time the logic gets triggered, it is checked if the current order was already processed. This works fine to fix the issue. However, I am still studying how to properly fix the root cause of the issue.
This custom implementation must replace the implementation from the sylius/invoicing-plugin
package. For this, the following service configuration is added to the config/services.yaml
file:
sylius_invoicing_plugin.event_producer.order_payment_paid:
public: true
class: App\EventProducer\OrderPaymentPaidProducer
arguments:
- "@sylius.event_bus"
- "@sylius_invoicing_plugin.date_time_provider"
- "@sylius_invoicing_plugin.repository.invoice"