Skip to main content

Avoid sending multiple invoice emails in Sylius

This blog post might be outdated!
This blog post was published more than one year ago and might be outdated!
· 2 min read
Stephan Hochdörfer
Head of IT Business Operations

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"