diff --git a/config/doctrine/Invoice.orm.xml b/config/doctrine/Invoice.orm.xml
index c754a98f..d592380f 100644
--- a/config/doctrine/Invoice.orm.xml
+++ b/config/doctrine/Invoice.orm.xml
@@ -10,6 +10,7 @@
+
diff --git a/config/services.xml b/config/services.xml
index 47ea616a..48bbe178 100644
--- a/config/services.xml
+++ b/config/services.xml
@@ -23,13 +23,25 @@
@SyliusInvoicingPlugin/assets/sylius-logo.png
%env(default:sylius_invoicing.default_logo_file:resolve:SYLIUS_INVOICING_LOGO_FILE)%
+ 3
+ 60000
+
+
+ %sylius_invoicing.email.retry_max_attempts%
+ %sylius_invoicing.email.retry_delay_ms%
+
+
+
+
%sylius_invoicing.pdf_generator.enabled%
+
+
diff --git a/config/services/cli.xml b/config/services/cli.xml
index ed6cb0c0..409abbfd 100644
--- a/config/services/cli.xml
+++ b/config/services/cli.xml
@@ -24,5 +24,11 @@
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
index b7f1955a..a32a8a0e 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -22,3 +22,4 @@ parameters:
- '/Method Sylius\\InvoicingPlugin\\Security\\Voter\\InvoiceVoter::supports\(\) has parameter \$attribute with no typehint specified./'
- '/Method Sylius\\InvoicingPlugin\\Security\\Voter\\InvoiceVoter::supports\(\) has parameter \$subject with no typehint specified./'
- '/expects string, string\|null given\.$/'
+ - '/Method Sylius\\Component\\Mailer\\Sender\\SenderInterface::send\(\) invoked with 7 parameters, 2-5 required\./'
diff --git a/src/Cli/RetryFailedInvoicesCommand.php b/src/Cli/RetryFailedInvoicesCommand.php
new file mode 100644
index 00000000..57f79429
--- /dev/null
+++ b/src/Cli/RetryFailedInvoicesCommand.php
@@ -0,0 +1,78 @@
+invoiceRepository->findUnsent();
+
+ if ([] === $failedInvoices) {
+ $output->writeln('No failed invoices found to retry.');
+
+ return Command::SUCCESS;
+ }
+
+ $dispatched = 0;
+
+ foreach ($failedInvoices as $invoice) {
+ $orderNumber = $invoice->order()->getNumber();
+
+ if (null === $orderNumber) {
+ continue;
+ }
+
+ try {
+ $this->commandBus->dispatch(new SendInvoiceEmail($orderNumber));
+ } catch (ExceptionInterface $e) {
+ $this->logger->error(
+ sprintf(
+ 'Failed to dispatch invoice resend command for order %s: %s',
+ $orderNumber,
+ $e->getMessage(),
+ ),
+ ['exception' => $e],
+ );
+ }
+ ++$dispatched;
+ }
+
+ $output->writeln(sprintf('Dispatched %d invoice resend command(s).', $dispatched));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/SendInvoiceEmail.php b/src/Command/SendInvoiceEmail.php
index b0410658..c5eaa937 100644
--- a/src/Command/SendInvoiceEmail.php
+++ b/src/Command/SendInvoiceEmail.php
@@ -13,14 +13,21 @@
namespace Sylius\InvoicingPlugin\Command;
-final class SendInvoiceEmail
+final readonly class SendInvoiceEmail
{
- public function __construct(private readonly string $orderNumber)
- {
+ public function __construct(
+ private string $orderNumber,
+ private int $attempt = 0,
+ ) {
}
public function orderNumber(): string
{
return $this->orderNumber;
}
+
+ public function attempt(): int
+ {
+ return $this->attempt;
+ }
}
diff --git a/src/CommandHandler/SendInvoiceEmailHandler.php b/src/CommandHandler/SendInvoiceEmailHandler.php
index b88ba9c0..b0f5e44f 100644
--- a/src/CommandHandler/SendInvoiceEmailHandler.php
+++ b/src/CommandHandler/SendInvoiceEmailHandler.php
@@ -20,12 +20,12 @@
use Sylius\InvoicingPlugin\Email\InvoiceEmailSenderInterface;
use Sylius\InvoicingPlugin\Entity\InvoiceInterface;
-final class SendInvoiceEmailHandler
+final readonly class SendInvoiceEmailHandler
{
public function __construct(
- private readonly InvoiceRepositoryInterface $invoiceRepository,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly InvoiceEmailSenderInterface $emailSender,
+ private InvoiceRepositoryInterface $invoiceRepository,
+ private OrderRepositoryInterface $orderRepository,
+ private InvoiceEmailSenderInterface $emailSender,
) {
}
@@ -48,6 +48,15 @@ public function __invoke(SendInvoiceEmail $command): void
return;
}
- $this->emailSender->sendInvoiceEmail($invoice, $customer->getEmail());
+ $customerEmail = $customer->getEmail();
+ if (null === $customerEmail) {
+ return;
+ }
+
+ $this->emailSender->sendInvoiceEmail($invoice, $customerEmail, $command->attempt());
+
+ if ($invoice->isPdfSent()) {
+ $this->invoiceRepository->add($invoice);
+ }
}
}
diff --git a/src/Doctrine/ORM/InvoiceRepository.php b/src/Doctrine/ORM/InvoiceRepository.php
index 39d52eaf..639a863f 100644
--- a/src/Doctrine/ORM/InvoiceRepository.php
+++ b/src/Doctrine/ORM/InvoiceRepository.php
@@ -43,4 +43,19 @@ public function findByOrderNumber(string $orderNumber): array
return $invoices;
}
+
+ public function findUnsent(): array
+ {
+ $invoices = $this
+ ->createQueryBuilder('invoice')
+ ->where('invoice.pdfSent = :pdfSent')
+ ->setParameter('pdfSent', false)
+ ->getQuery()
+ ->getResult()
+ ;
+
+ Assert::isArray($invoices);
+
+ return $invoices;
+ }
}
diff --git a/src/Doctrine/ORM/InvoiceRepositoryInterface.php b/src/Doctrine/ORM/InvoiceRepositoryInterface.php
index 8d58be33..c609d30e 100644
--- a/src/Doctrine/ORM/InvoiceRepositoryInterface.php
+++ b/src/Doctrine/ORM/InvoiceRepositoryInterface.php
@@ -22,4 +22,9 @@ interface InvoiceRepositoryInterface extends RepositoryInterface
public function findOneByOrder(OrderInterface $order): ?InvoiceInterface;
public function findByOrderNumber(string $orderNumber): array;
+
+ /**
+ * @return array
+ */
+ public function findUnsent(): array;
}
diff --git a/src/Email/InvoiceEmailRetryScheduler.php b/src/Email/InvoiceEmailRetryScheduler.php
new file mode 100644
index 00000000..b8c0e382
--- /dev/null
+++ b/src/Email/InvoiceEmailRetryScheduler.php
@@ -0,0 +1,84 @@
+= $this->maxAttempts) {
+ $this->logger?->warning(
+ sprintf(
+ 'Invoice email retry aborted for invoice "%s" (%s) after %d attempts.',
+ $invoice->number(),
+ $invoice->id(),
+ $attempt,
+ ),
+ ['customerEmail' => $customerEmail],
+ );
+
+ return;
+ }
+
+ $orderNumber = $invoice->order()->getNumber();
+ if (null === $orderNumber) {
+ $this->logger?->warning(
+ sprintf(
+ 'Invoice email retry skipped because order number is missing for invoice "%s" (%s).',
+ $invoice->number(),
+ $invoice->id(),
+ ),
+ ['customerEmail' => $customerEmail],
+ );
+
+ return;
+ }
+
+ $nextAttempt = $attempt + 1;
+ $message = new SendInvoiceEmail($orderNumber, $nextAttempt);
+ $envelope = new Envelope($message, [new DelayStamp($this->retryDelayMilliseconds)]);
+
+ try {
+ $this->commandBus->dispatch($envelope);
+ } catch (ExceptionInterface $e) {
+ $this->logger?->error($e->getMessage());
+ }
+
+ $this->logger?->info(
+ sprintf(
+ 'Scheduled invoice email retry #%d for invoice "%s" (%s) to "%s".',
+ $nextAttempt,
+ $invoice->number(),
+ $invoice->id(),
+ $customerEmail,
+ ),
+ );
+ }
+}
diff --git a/src/Email/InvoiceEmailRetrySchedulerInterface.php b/src/Email/InvoiceEmailRetrySchedulerInterface.php
new file mode 100644
index 00000000..f28b0287
--- /dev/null
+++ b/src/Email/InvoiceEmailRetrySchedulerInterface.php
@@ -0,0 +1,21 @@
+hasEnabledPdfFileGenerator) {
- $this->emailSender->send(Emails::INVOICE_GENERATED, [$customerEmail], ['invoice' => $invoice]);
+ $this->emailSender->send(
+ Emails::INVOICE_GENERATED,
+ [$customerEmail],
+ ['invoice' => $invoice],
+ [],
+ [],
+ [],
+ [],
+ );
return;
}
- $invoicePdf = $this->invoiceFileProvider->provide($invoice);
- $invoicePdfPath = $invoicePdf->fullPath();
- Assert::notNull($invoicePdfPath);
+ try {
+ $invoicePdf = $this->invoiceFileProvider->provide($invoice);
+ $invoicePdfPath = $invoicePdf->fullPath();
+ Assert::notNull($invoicePdfPath);
- $this->emailSender->send(Emails::INVOICE_GENERATED, [$customerEmail], ['invoice' => $invoice], [$invoicePdfPath]);
+ if (!is_file($invoicePdfPath) || !is_readable($invoicePdfPath)) {
+ throw InvoiceFileGenerationFailedException::forInvoice(
+ $invoice,
+ new \RuntimeException(sprintf('Invoice PDF file "%s" is not readable.', $invoicePdfPath)),
+ );
+ }
+
+ $this->emailSender->send(
+ Emails::INVOICE_GENERATED,
+ [$customerEmail],
+ ['invoice' => $invoice],
+ [$invoicePdfPath],
+ [],
+ [],
+ [],
+ );
+
+ $invoice->setPdfSent(true);
+ } catch (InvoiceFileGenerationFailedException $exception) {
+ if (null !== $this->logger) {
+ $this->logger->error(
+ sprintf(
+ 'Invoice PDF for invoice "%s" (%s) could not be generated. Email to "%s" will not be sent.',
+ $invoice->number(),
+ $invoice->id(),
+ $customerEmail,
+ ),
+ ['exception' => $exception],
+ );
+ }
+
+ if (null !== $this->retryScheduler) {
+ $this->retryScheduler->scheduleRetry($invoice, $customerEmail, $attempt);
+ }
+
+ $invoice->setPdfSent(false);
+ }
}
}
diff --git a/src/Email/InvoiceEmailSenderInterface.php b/src/Email/InvoiceEmailSenderInterface.php
index 8798270b..07e1df66 100644
--- a/src/Email/InvoiceEmailSenderInterface.php
+++ b/src/Email/InvoiceEmailSenderInterface.php
@@ -17,5 +17,5 @@
interface InvoiceEmailSenderInterface
{
- public function sendInvoiceEmail(InvoiceInterface $invoice, string $customerEmail): void;
+ public function sendInvoiceEmail(InvoiceInterface $invoice, string $customerEmail, int $attempt = 0): void;
}
diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php
index 969e6bd8..61ad46fd 100644
--- a/src/Entity/Invoice.php
+++ b/src/Entity/Invoice.php
@@ -36,6 +36,7 @@ public function __construct(
protected ChannelInterface $channel,
protected string $paymentState,
protected InvoiceShopBillingDataInterface $shopBillingData,
+ protected bool $pdfSent = false,
) {
$this->issuedAt = clone $issuedAt;
@@ -143,4 +144,14 @@ public function paymentState(): string
{
return $this->paymentState;
}
+
+ public function isPdfSent(): bool
+ {
+ return $this->pdfSent;
+ }
+
+ public function setPdfSent(bool $pdfSent): void
+ {
+ $this->pdfSent = $pdfSent;
+ }
}
diff --git a/src/Entity/InvoiceInterface.php b/src/Entity/InvoiceInterface.php
index 0ddede9a..cdb5d017 100644
--- a/src/Entity/InvoiceInterface.php
+++ b/src/Entity/InvoiceInterface.php
@@ -53,4 +53,8 @@ public function channel(): ChannelInterface;
public function shopBillingData(): InvoiceShopBillingDataInterface;
public function paymentState(): string;
+
+ public function isPdfSent(): bool;
+
+ public function setPdfSent(bool $pdfSent): void;
}
diff --git a/src/Exception/InvoiceFileGenerationFailedException.php b/src/Exception/InvoiceFileGenerationFailedException.php
new file mode 100644
index 00000000..39e91e9f
--- /dev/null
+++ b/src/Exception/InvoiceFileGenerationFailedException.php
@@ -0,0 +1,38 @@
+number(),
+ $invoice->id(),
+ ),
+ 0,
+ $previous,
+ );
+ }
+}
diff --git a/src/Migrations/Version20251016094233.php b/src/Migrations/Version20251016094233.php
new file mode 100644
index 00000000..bec0d89a
--- /dev/null
+++ b/src/Migrations/Version20251016094233.php
@@ -0,0 +1,32 @@
+addSql('ALTER TABLE sylius_invoicing_plugin_invoice ADD pdf_sent BOOLEAN NOT NULL DEFAULT FALSE');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('ALTER TABLE sylius_invoicing_plugin_invoice DROP pdf_sent');
+ }
+}
diff --git a/src/Migrations/Version20251016095237.php b/src/Migrations/Version20251016095237.php
new file mode 100644
index 00000000..63190e10
--- /dev/null
+++ b/src/Migrations/Version20251016095237.php
@@ -0,0 +1,32 @@
+addSql('ALTER TABLE sylius_invoicing_plugin_invoice ADD pdf_sent TINYINT(1) NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('ALTER TABLE sylius_invoicing_plugin_invoice DROP pdf_sent');
+ }
+}
diff --git a/src/Provider/InvoiceFileProvider.php b/src/Provider/InvoiceFileProvider.php
index 711ce694..8b1fe8e2 100644
--- a/src/Provider/InvoiceFileProvider.php
+++ b/src/Provider/InvoiceFileProvider.php
@@ -16,19 +16,20 @@
use Gaufrette\Exception\FileNotFound;
use Gaufrette\FilesystemInterface;
use Sylius\InvoicingPlugin\Entity\InvoiceInterface;
+use Sylius\InvoicingPlugin\Exception\InvoiceFileGenerationFailedException;
use Sylius\InvoicingPlugin\Generator\InvoiceFileNameGeneratorInterface;
use Sylius\InvoicingPlugin\Generator\InvoicePdfFileGeneratorInterface;
use Sylius\InvoicingPlugin\Manager\InvoiceFileManagerInterface;
use Sylius\InvoicingPlugin\Model\InvoicePdf;
-final class InvoiceFileProvider implements InvoiceFileProviderInterface
+final readonly class InvoiceFileProvider implements InvoiceFileProviderInterface
{
public function __construct(
- private readonly InvoiceFileNameGeneratorInterface $invoiceFileNameGenerator,
- private readonly FilesystemInterface $filesystem,
- private readonly InvoicePdfFileGeneratorInterface $invoicePdfFileGenerator,
- private readonly InvoiceFileManagerInterface $invoiceFileManager,
- private readonly string $invoicesDirectory,
+ private InvoiceFileNameGeneratorInterface $invoiceFileNameGenerator,
+ private FilesystemInterface $filesystem,
+ private InvoicePdfFileGeneratorInterface $invoicePdfFileGenerator,
+ private InvoiceFileManagerInterface $invoiceFileManager,
+ private string $invoicesDirectory,
) {
}
@@ -40,8 +41,12 @@ public function provide(InvoiceInterface $invoice): InvoicePdf
$invoiceFile = $this->filesystem->get($invoiceFileName);
$invoicePdf = new InvoicePdf($invoiceFileName, $invoiceFile->getContent());
} catch (FileNotFound) {
- $invoicePdf = $this->invoicePdfFileGenerator->generate($invoice);
- $this->invoiceFileManager->save($invoicePdf);
+ try {
+ $invoicePdf = $this->invoicePdfFileGenerator->generate($invoice);
+ $this->invoiceFileManager->save($invoicePdf);
+ } catch (\Throwable $exception) {
+ throw InvoiceFileGenerationFailedException::forInvoice($invoice, $exception);
+ }
}
$invoicePdf->setFullPath($this->invoicesDirectory . '/' . $invoiceFileName);
diff --git a/tests/Unit/CommandHandler/SendInvoiceEmailHandlerTest.php b/tests/Unit/CommandHandler/SendInvoiceEmailHandlerTest.php
index cc962ffa..36c8c5f8 100644
--- a/tests/Unit/CommandHandler/SendInvoiceEmailHandlerTest.php
+++ b/tests/Unit/CommandHandler/SendInvoiceEmailHandlerTest.php
@@ -13,6 +13,7 @@
namespace Tests\Sylius\InvoicingPlugin\Unit\CommandHandler;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -27,12 +28,22 @@
final class SendInvoiceEmailHandlerTest extends TestCase
{
+ private const ORDER_NUMBER = '0000001';
+
+ private const CUSTOMER_EMAIL = 'customer@example.com';
+
private InvoiceRepositoryInterface&MockObject $invoiceRepository;
private MockObject&OrderRepositoryInterface $orderRepository;
private InvoiceEmailSenderInterface&MockObject $emailSender;
+ private MockObject&OrderInterface $order;
+
+ private CustomerInterface&MockObject $customer;
+
+ private InvoiceInterface&MockObject $invoice;
+
private SendInvoiceEmailHandler $handler;
protected function setUp(): void
@@ -41,6 +52,9 @@ protected function setUp(): void
$this->invoiceRepository = $this->createMock(InvoiceRepositoryInterface::class);
$this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
$this->emailSender = $this->createMock(InvoiceEmailSenderInterface::class);
+ $this->order = $this->createMock(OrderInterface::class);
+ $this->customer = $this->createMock(CustomerInterface::class);
+ $this->invoice = $this->createMock(InvoiceInterface::class);
$this->handler = new SendInvoiceEmailHandler(
$this->invoiceRepository,
@@ -50,116 +64,215 @@ protected function setUp(): void
}
#[Test]
- public function it_requests_an_email_with_an_invoice_to_be_sent(): void
+ #[DataProvider('attemptProvider')]
+ public function it_sends_invoice_email_with_different_attempts(int $attempt): void
{
- $invoice = $this->createMock(InvoiceInterface::class);
- $order = $this->createMock(OrderInterface::class);
- $customer = $this->createMock(CustomerInterface::class);
+ $this->expectOrderFound();
+ $this->expectCustomerFound();
+ $this->expectInvoiceFound();
- $this->orderRepository
+ $this->invoice
->expects(self::once())
- ->method('findOneByNumber')
- ->with('0000001')
- ->willReturn($order);
+ ->method('isPdfSent')
+ ->willReturn(false);
- $order
- ->expects(self::once())
- ->method('getCustomer')
- ->willReturn($customer);
+ $this->invoiceRepository
+ ->expects(self::never())
+ ->method('add');
- $customer
+ $this->emailSender
->expects(self::once())
- ->method('getEmail')
- ->willReturn('shop@example.com');
+ ->method('sendInvoiceEmail')
+ ->with($this->invoice, self::CUSTOMER_EMAIL, $attempt);
- $this->invoiceRepository
+ ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER, $attempt));
+ }
+
+ #[Test]
+ #[DataProvider('pdfSentStatusProvider')]
+ public function it_persists_invoice_only_when_pdf_was_sent(bool $pdfSent, bool $shouldPersist): void
+ {
+ $this->expectOrderFound();
+ $this->expectCustomerFound();
+ $this->expectInvoiceFound();
+
+ $this->invoice
->expects(self::once())
- ->method('findOneByOrder')
- ->with($order)
- ->willReturn($invoice);
+ ->method('isPdfSent')
+ ->willReturn($pdfSent);
+
+ if ($shouldPersist) {
+ $this->invoiceRepository
+ ->expects(self::once())
+ ->method('add')
+ ->with($this->invoice);
+ } else {
+ $this->invoiceRepository
+ ->expects(self::never())
+ ->method('add');
+ }
$this->emailSender
->expects(self::once())
->method('sendInvoiceEmail')
- ->with($invoice, 'shop@example.com');
+ ->with($this->invoice, self::CUSTOMER_EMAIL, 0);
- ($this->handler)(new SendInvoiceEmail('0000001'));
+ ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER));
}
#[Test]
- public function it_does_not_request_an_email_to_be_sent_if_order_was_not_found(): void
+ public function it_does_not_send_email_when_order_not_found(): void
{
$this->orderRepository
->expects(self::once())
->method('findOneByNumber')
- ->with('0000001')
+ ->with(self::ORDER_NUMBER)
->willReturn(null);
$this->invoiceRepository
- ->expects($this->never())
+ ->expects(self::never())
->method('findOneByOrder');
$this->emailSender
- ->expects($this->never())
+ ->expects(self::never())
->method('sendInvoiceEmail');
- ($this->handler)(new SendInvoiceEmail('0000001'));
+ $this->invoiceRepository
+ ->expects(self::never())
+ ->method('add');
+
+ ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER));
}
#[Test]
- public function it_does_not_request_an_email_to_be_sent_if_customer_was_not_found(): void
+ public function it_does_not_send_email_when_customer_not_found(): void
{
- $order = $this->createMock(OrderInterface::class);
-
- $this->orderRepository
- ->expects(self::once())
- ->method('findOneByNumber')
- ->with('0000001')
- ->willReturn($order);
+ $this->expectOrderFound();
- $order
+ $this->order
->expects(self::once())
->method('getCustomer')
->willReturn(null);
$this->invoiceRepository
- ->expects($this->never())
+ ->expects(self::never())
->method('findOneByOrder');
$this->emailSender
- ->expects($this->never())
+ ->expects(self::never())
->method('sendInvoiceEmail');
- ($this->handler)(new SendInvoiceEmail('0000001'));
+ $this->invoiceRepository
+ ->expects(self::never())
+ ->method('add');
+
+ ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER));
}
#[Test]
- public function it_does_not_request_an_email_to_be_sent_if_invoice_was_not_found(): void
+ public function it_does_not_send_email_when_invoice_not_found(): void
{
- $order = $this->createMock(OrderInterface::class);
- $customer = $this->createMock(CustomerInterface::class);
+ $this->expectOrderFound();
- $this->orderRepository
+ $this->order
->expects(self::once())
- ->method('findOneByNumber')
- ->with('0000001')
- ->willReturn($order);
+ ->method('getCustomer')
+ ->willReturn($this->customer);
- $order
+ $this->invoiceRepository
+ ->expects(self::once())
+ ->method('findOneByOrder')
+ ->with($this->order)
+ ->willReturn(null);
+
+ $this->emailSender
+ ->expects(self::never())
+ ->method('sendInvoiceEmail');
+
+ $this->invoiceRepository
+ ->expects(self::never())
+ ->method('add');
+
+ ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER));
+ }
+
+ #[Test]
+ public function it_does_not_send_email_when_customer_email_is_null(): void
+ {
+ $this->expectOrderFound();
+
+ $this->order
->expects(self::once())
->method('getCustomer')
- ->willReturn($customer);
+ ->willReturn($this->customer);
+
+ $this->customer
+ ->expects(self::once())
+ ->method('getEmail')
+ ->willReturn(null);
$this->invoiceRepository
->expects(self::once())
->method('findOneByOrder')
- ->with($order)
- ->willReturn(null);
+ ->with($this->order)
+ ->willReturn($this->invoice);
$this->emailSender
- ->expects($this->never())
+ ->expects(self::never())
->method('sendInvoiceEmail');
- ($this->handler)(new SendInvoiceEmail('0000001'));
+ $this->invoiceRepository
+ ->expects(self::never())
+ ->method('add');
+
+ ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER));
+ }
+
+ public static function attemptProvider(): array
+ {
+ return [
+ 'first attempt' => [0],
+ 'second attempt' => [1],
+ 'third attempt' => [2],
+ ];
+ }
+
+ public static function pdfSentStatusProvider(): array
+ {
+ return [
+ 'pdf not sent' => [false, false],
+ 'pdf sent successfully' => [true, true],
+ ];
+ }
+
+ private function expectOrderFound(): void
+ {
+ $this->orderRepository
+ ->expects(self::once())
+ ->method('findOneByNumber')
+ ->with(self::ORDER_NUMBER)
+ ->willReturn($this->order);
+ }
+
+ private function expectCustomerFound(): void
+ {
+ $this->order
+ ->expects(self::once())
+ ->method('getCustomer')
+ ->willReturn($this->customer);
+
+ $this->customer
+ ->expects(self::once())
+ ->method('getEmail')
+ ->willReturn(self::CUSTOMER_EMAIL);
+ }
+
+ private function expectInvoiceFound(): void
+ {
+ $this->invoiceRepository
+ ->expects(self::once())
+ ->method('findOneByOrder')
+ ->with($this->order)
+ ->willReturn($this->invoice);
}
}
diff --git a/tests/Unit/Email/InvoiceEmailRetrySchedulerTest.php b/tests/Unit/Email/InvoiceEmailRetrySchedulerTest.php
new file mode 100644
index 00000000..c4e2ec3d
--- /dev/null
+++ b/tests/Unit/Email/InvoiceEmailRetrySchedulerTest.php
@@ -0,0 +1,237 @@
+commandBus = $this->createMock(MessageBusInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->invoice = $this->createMock(InvoiceInterface::class);
+ $this->order = $this->createMock(OrderInterface::class);
+
+ $this->invoice
+ ->method('order')
+ ->willReturn($this->order);
+
+ $this->invoice
+ ->method('number')
+ ->willReturn('2024/11/0001');
+
+ $this->invoice
+ ->method('id')
+ ->willReturn('INV-0001');
+ }
+
+ #[Test]
+ public function it_implements_invoice_email_retry_scheduler_interface(): void
+ {
+ $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000);
+
+ self::assertInstanceOf(InvoiceEmailRetrySchedulerInterface::class, $scheduler);
+ }
+
+ #[Test]
+ #[DataProvider('retryAttemptProvider')]
+ public function it_schedules_retry_with_correct_attempt_number_and_delay(
+ int $currentAttempt,
+ int $expectedNextAttempt,
+ int $retryDelay,
+ ): void {
+ $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, $retryDelay, $this->logger);
+
+ $this->order
+ ->method('getNumber')
+ ->willReturn('0000001');
+
+ $this->commandBus
+ ->expects(self::once())
+ ->method('dispatch')
+ ->with(self::callback(function (Envelope $envelope) use ($expectedNextAttempt, $retryDelay) {
+ $message = $envelope->getMessage();
+ $stamps = $envelope->all(DelayStamp::class);
+
+ return $message instanceof SendInvoiceEmail &&
+ $message->orderNumber() === '0000001' &&
+ $message->attempt() === $expectedNextAttempt &&
+ count($stamps) === 1 &&
+ $stamps[0]->getDelay() === $retryDelay;
+ }))
+ ->willReturn(new Envelope(new SendInvoiceEmail('0000001', $expectedNextAttempt)));
+
+ $this->logger
+ ->expects(self::once())
+ ->method('info')
+ ->with(
+ sprintf(
+ 'Scheduled invoice email retry #%d for invoice "2024/11/0001" (INV-0001) to "customer@example.com".',
+ $expectedNextAttempt,
+ ),
+ );
+
+ $scheduler->scheduleRetry($this->invoice, 'customer@example.com', $currentAttempt);
+ }
+
+ #[Test]
+ #[DataProvider('abortScenarioProvider')]
+ public function it_aborts_retry_when_max_attempts_reached(
+ int $attempt,
+ string $orderNumber,
+ string $expectedWarningMessage,
+ ): void {
+ $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000, $this->logger);
+
+ $this->order
+ ->method('getNumber')
+ ->willReturn($orderNumber);
+
+ $this->commandBus
+ ->expects(self::never())
+ ->method('dispatch');
+
+ $this->logger
+ ->expects(self::once())
+ ->method('warning')
+ ->with(
+ $expectedWarningMessage,
+ ['customerEmail' => 'customer@example.com'],
+ );
+
+ $scheduler->scheduleRetry($this->invoice, 'customer@example.com', $attempt);
+ }
+
+ #[Test]
+ public function it_skips_retry_when_order_number_is_missing(): void
+ {
+ $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000, $this->logger);
+
+ $this->order
+ ->method('getNumber')
+ ->willReturn(null);
+
+ $this->commandBus
+ ->expects(self::never())
+ ->method('dispatch');
+
+ $this->logger
+ ->expects(self::once())
+ ->method('warning')
+ ->with(
+ 'Invoice email retry skipped because order number is missing for invoice "2024/11/0001" (INV-0001).',
+ ['customerEmail' => 'customer@example.com'],
+ );
+
+ $scheduler->scheduleRetry($this->invoice, 'customer@example.com', 0);
+ }
+
+ #[Test]
+ public function it_logs_error_when_dispatch_fails(): void
+ {
+ $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000, $this->logger);
+
+ $this->order
+ ->method('getNumber')
+ ->willReturn('0000001');
+
+ $exception = new class('Transport failed') extends \Exception implements ExceptionInterface {
+ };
+
+ $this->commandBus
+ ->expects(self::once())
+ ->method('dispatch')
+ ->willThrowException($exception);
+
+ $this->logger
+ ->expects(self::once())
+ ->method('error')
+ ->with('Transport failed');
+
+ $this->logger
+ ->expects(self::once())
+ ->method('info')
+ ->with(
+ 'Scheduled invoice email retry #1 for invoice "2024/11/0001" (INV-0001) to "customer@example.com".',
+ );
+
+ $scheduler->scheduleRetry($this->invoice, 'customer@example.com', 0);
+ }
+
+ #[Test]
+ public function it_works_without_logger(): void
+ {
+ $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000);
+
+ $this->order
+ ->method('getNumber')
+ ->willReturn('0000001');
+
+ $this->commandBus
+ ->expects(self::once())
+ ->method('dispatch')
+ ->willReturn(new Envelope(new SendInvoiceEmail('0000001', 1)));
+
+ $scheduler->scheduleRetry($this->invoice, 'customer@example.com', 0);
+ }
+
+ public static function retryAttemptProvider(): array
+ {
+ return [
+ 'first attempt' => [0, 1, 60000],
+ 'second attempt' => [1, 2, 60000],
+ 'third attempt' => [2, 3, 60000],
+ 'custom delay' => [0, 1, 120000],
+ ];
+ }
+
+ public static function abortScenarioProvider(): array
+ {
+ return [
+ 'max attempts reached' => [
+ 3,
+ '0000001',
+ 'Invoice email retry aborted for invoice "2024/11/0001" (INV-0001) after 3 attempts.',
+ ],
+ 'attempts exceeded' => [
+ 5,
+ '0000001',
+ 'Invoice email retry aborted for invoice "2024/11/0001" (INV-0001) after 5 attempts.',
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Email/InvoiceEmailSenderTest.php b/tests/Unit/Email/InvoiceEmailSenderTest.php
index c25f2761..1ada833c 100644
--- a/tests/Unit/Email/InvoiceEmailSenderTest.php
+++ b/tests/Unit/Email/InvoiceEmailSenderTest.php
@@ -13,28 +13,60 @@
namespace Tests\Sylius\InvoicingPlugin\Unit\Email;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
use Sylius\Component\Mailer\Sender\SenderInterface;
use Sylius\InvoicingPlugin\Email\Emails;
+use Sylius\InvoicingPlugin\Email\InvoiceEmailRetrySchedulerInterface;
use Sylius\InvoicingPlugin\Email\InvoiceEmailSender;
use Sylius\InvoicingPlugin\Email\InvoiceEmailSenderInterface;
use Sylius\InvoicingPlugin\Entity\InvoiceInterface;
+use Sylius\InvoicingPlugin\Exception\InvoiceFileGenerationFailedException;
use Sylius\InvoicingPlugin\Model\InvoicePdf;
use Sylius\InvoicingPlugin\Provider\InvoiceFileProviderInterface;
final class InvoiceEmailSenderTest extends TestCase
{
+ private const CUSTOMER_EMAIL = 'customer@example.com';
+
+ private const INVOICE_NUMBER = '2024/11/0001';
+
+ private const INVOICE_ID = 'INV-0001';
+
private MockObject&SenderInterface $sender;
private InvoiceFileProviderInterface&MockObject $invoiceFileProvider;
+ private InvoiceInterface&MockObject $invoice;
+
+ private ?string $temporaryFilePath = null;
+
protected function setUp(): void
{
parent::setUp();
$this->sender = $this->createMock(SenderInterface::class);
$this->invoiceFileProvider = $this->createMock(InvoiceFileProviderInterface::class);
+ $this->invoice = $this->createMock(InvoiceInterface::class);
+
+ $this->invoice
+ ->method('number')
+ ->willReturn(self::INVOICE_NUMBER);
+
+ $this->invoice
+ ->method('id')
+ ->willReturn(self::INVOICE_ID);
+ }
+
+ protected function tearDown(): void
+ {
+ if (null !== $this->temporaryFilePath && file_exists($this->temporaryFilePath)) {
+ @unlink($this->temporaryFilePath);
+ }
+
+ parent::tearDown();
}
#[Test]
@@ -46,48 +78,289 @@ public function it_implements_invoice_email_sender_interface(): void
}
#[Test]
- public function it_sends_an_invoice_to_a_given_email_address(): void
+ #[DataProvider('attemptProvider')]
+ public function it_sends_invoice_email_with_pdf_attachment(int $attempt): void
{
$invoiceEmailSender = new InvoiceEmailSender($this->sender, $this->invoiceFileProvider);
- $invoice = $this->createMock(InvoiceInterface::class);
- $invoicePdf = new InvoicePdf('invoice.pdf', 'CONTENT');
- $invoicePdf->setFullPath('/path/to/invoices/invoice.pdf');
+ $temporaryPath = $this->createTemporaryPdfFile();
+ $invoicePdf = new InvoicePdf('invoice.pdf', 'PDF_CONTENT');
+ $invoicePdf->setFullPath($temporaryPath);
$this->invoiceFileProvider
->expects(self::once())
->method('provide')
- ->with($invoice)
+ ->with($this->invoice)
->willReturn($invoicePdf);
+ $this->invoice
+ ->expects(self::once())
+ ->method('setPdfSent')
+ ->with(true);
+
$this->sender
->expects(self::once())
->method('send')
->with(
Emails::INVOICE_GENERATED,
- ['sylius@example.com'],
- ['invoice' => $invoice],
- ['/path/to/invoices/invoice.pdf'],
+ [self::CUSTOMER_EMAIL],
+ ['invoice' => $this->invoice],
+ [$temporaryPath],
+ [],
+ [],
+ [],
);
- $invoiceEmailSender->sendInvoiceEmail($invoice, 'sylius@example.com');
+ $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL, $attempt);
}
#[Test]
- public function it_sends_an_invoice_without_attachment_to_a_given_email_address(): void
+ public function it_sends_invoice_email_without_pdf_when_pdf_generation_disabled(): void
{
$invoiceEmailSender = new InvoiceEmailSender($this->sender, $this->invoiceFileProvider, false);
- $invoice = $this->createMock(InvoiceInterface::class);
$this->invoiceFileProvider
- ->expects($this->never())
+ ->expects(self::never())
->method('provide');
+ $this->invoice
+ ->expects(self::never())
+ ->method('setPdfSent');
+
$this->sender
->expects(self::once())
->method('send')
- ->with(Emails::INVOICE_GENERATED, ['sylius@example.com'], ['invoice' => $invoice]);
+ ->with(
+ Emails::INVOICE_GENERATED,
+ [self::CUSTOMER_EMAIL],
+ ['invoice' => $this->invoice],
+ [],
+ [],
+ [],
+ [],
+ );
+
+ $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL);
+ }
+
+ #[Test]
+ public function it_handles_pdf_file_not_readable_exception(): void
+ {
+ $logger = $this->createMock(LoggerInterface::class);
+ $retryScheduler = $this->createMock(InvoiceEmailRetrySchedulerInterface::class);
+
+ $invoiceEmailSender = new InvoiceEmailSender(
+ $this->sender,
+ $this->invoiceFileProvider,
+ true,
+ $logger,
+ $retryScheduler,
+ );
+
+ $invoicePdf = new InvoicePdf('invoice.pdf', 'PDF_CONTENT');
+ $invoicePdf->setFullPath('/nonexistent/path/invoice.pdf');
+
+ $this->invoiceFileProvider
+ ->expects(self::once())
+ ->method('provide')
+ ->with($this->invoice)
+ ->willReturn($invoicePdf);
+
+ $this->invoice
+ ->expects(self::once())
+ ->method('setPdfSent')
+ ->with(false);
+
+ $logger
+ ->expects(self::once())
+ ->method('error')
+ ->with(
+ sprintf(
+ 'Invoice PDF for invoice "%s" (%s) could not be generated. Email to "%s" will not be sent.',
+ self::INVOICE_NUMBER,
+ self::INVOICE_ID,
+ self::CUSTOMER_EMAIL,
+ ),
+ self::callback(fn (array $context) => isset($context['exception']) && $context['exception'] instanceof InvoiceFileGenerationFailedException),
+ );
+
+ $retryScheduler
+ ->expects(self::once())
+ ->method('scheduleRetry')
+ ->with($this->invoice, self::CUSTOMER_EMAIL, 0);
+
+ $this->sender
+ ->expects(self::never())
+ ->method('send');
+
+ $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL);
+ }
+
+ #[Test]
+ public function it_schedules_retry_when_pdf_generation_fails(): void
+ {
+ $logger = $this->createMock(LoggerInterface::class);
+ $retryScheduler = $this->createMock(InvoiceEmailRetrySchedulerInterface::class);
+
+ $invoiceEmailSender = new InvoiceEmailSender(
+ $this->sender,
+ $this->invoiceFileProvider,
+ true,
+ $logger,
+ $retryScheduler,
+ );
+
+ $this->invoiceFileProvider
+ ->expects(self::once())
+ ->method('provide')
+ ->with($this->invoice)
+ ->willThrowException(InvoiceFileGenerationFailedException::occur());
+
+ $this->invoice
+ ->expects(self::once())
+ ->method('setPdfSent')
+ ->with(false);
+
+ $logger
+ ->expects(self::once())
+ ->method('error')
+ ->with(
+ sprintf(
+ 'Invoice PDF for invoice "%s" (%s) could not be generated. Email to "%s" will not be sent.',
+ self::INVOICE_NUMBER,
+ self::INVOICE_ID,
+ self::CUSTOMER_EMAIL,
+ ),
+ self::callback(fn (array $context) => isset($context['exception']) && $context['exception'] instanceof InvoiceFileGenerationFailedException),
+ );
+
+ $retryScheduler
+ ->expects(self::once())
+ ->method('scheduleRetry')
+ ->with($this->invoice, self::CUSTOMER_EMAIL, 0);
+
+ $this->sender
+ ->expects(self::never())
+ ->method('send');
+
+ $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL);
+ }
+
+ #[Test]
+ public function it_handles_pdf_generation_failure_without_retry_scheduler(): void
+ {
+ $logger = $this->createMock(LoggerInterface::class);
+
+ $invoiceEmailSender = new InvoiceEmailSender(
+ $this->sender,
+ $this->invoiceFileProvider,
+ true,
+ $logger,
+ null,
+ );
+
+ $this->invoiceFileProvider
+ ->expects(self::once())
+ ->method('provide')
+ ->with($this->invoice)
+ ->willThrowException(InvoiceFileGenerationFailedException::occur());
+
+ $this->invoice
+ ->expects(self::once())
+ ->method('setPdfSent')
+ ->with(false);
+
+ $logger
+ ->expects(self::once())
+ ->method('error');
+
+ $this->sender
+ ->expects(self::never())
+ ->method('send');
+
+ $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL);
+ }
+
+ #[Test]
+ public function it_handles_pdf_generation_failure_without_logger(): void
+ {
+ $retryScheduler = $this->createMock(InvoiceEmailRetrySchedulerInterface::class);
+
+ $invoiceEmailSender = new InvoiceEmailSender(
+ $this->sender,
+ $this->invoiceFileProvider,
+ true,
+ null,
+ $retryScheduler,
+ );
+
+ $this->invoiceFileProvider
+ ->expects(self::once())
+ ->method('provide')
+ ->with($this->invoice)
+ ->willThrowException(InvoiceFileGenerationFailedException::occur());
+
+ $this->invoice
+ ->expects(self::once())
+ ->method('setPdfSent')
+ ->with(false);
+
+ $retryScheduler
+ ->expects(self::once())
+ ->method('scheduleRetry')
+ ->with($this->invoice, self::CUSTOMER_EMAIL, 0);
+
+ $this->sender
+ ->expects(self::never())
+ ->method('send');
+
+ $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL);
+ }
+
+ #[Test]
+ public function it_handles_pdf_generation_failure_without_logger_and_retry_scheduler(): void
+ {
+ $invoiceEmailSender = new InvoiceEmailSender(
+ $this->sender,
+ $this->invoiceFileProvider,
+ true,
+ null,
+ null,
+ );
+
+ $this->invoiceFileProvider
+ ->expects(self::once())
+ ->method('provide')
+ ->with($this->invoice)
+ ->willThrowException(InvoiceFileGenerationFailedException::occur());
+
+ $this->invoice
+ ->expects(self::once())
+ ->method('setPdfSent')
+ ->with(false);
+
+ $this->sender
+ ->expects(self::never())
+ ->method('send');
+
+ $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL);
+ }
+
+ public static function attemptProvider(): array
+ {
+ return [
+ 'first attempt' => [0],
+ 'second attempt' => [1],
+ 'third attempt' => [2],
+ ];
+ }
+
+ private function createTemporaryPdfFile(): string
+ {
+ $this->temporaryFilePath = tempnam(sys_get_temp_dir(), 'invoice_pdf_');
+ self::assertNotFalse($this->temporaryFilePath);
+ self::assertNotFalse(file_put_contents($this->temporaryFilePath, 'PDF_CONTENT'));
- $invoiceEmailSender->sendInvoiceEmail($invoice, 'sylius@example.com');
+ return $this->temporaryFilePath;
}
}
diff --git a/tests/Unit/Entity/InvoiceTest.php b/tests/Unit/Entity/InvoiceTest.php
index be210558..9a52f6ae 100644
--- a/tests/Unit/Entity/InvoiceTest.php
+++ b/tests/Unit/Entity/InvoiceTest.php
@@ -14,7 +14,9 @@
namespace Tests\Sylius\InvoicingPlugin\Unit\Entity;
use Doctrine\Common\Collections\ArrayCollection;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\OrderInterface;
@@ -28,19 +30,27 @@
final class InvoiceTest extends TestCase
{
- private BillingDataInterface $billingData;
+ private const ID = '7903c83a-4c5e-4bcf-81d8-9dc304c6a353';
- private LineItemInterface $lineItem;
+ private const INVOICE_NUMBER = '2019/01/000000001';
- private TaxItemInterface $taxItem;
+ private const CURRENCY_CODE = 'USD';
- private ChannelInterface $channel;
+ private const LOCALE_CODE = 'en_US';
- private InvoiceShopBillingDataInterface $shopBillingData;
+ private const TOTAL = 10300;
- private OrderInterface $order;
+ private BillingDataInterface&MockObject $billingData;
- private Invoice $invoice;
+ private LineItemInterface&MockObject $lineItem;
+
+ private MockObject&TaxItemInterface $taxItem;
+
+ private ChannelInterface&MockObject $channel;
+
+ private InvoiceShopBillingDataInterface&MockObject $shopBillingData;
+
+ private MockObject&OrderInterface $order;
private \DateTimeImmutable $issuedAt;
@@ -54,58 +64,282 @@ protected function setUp(): void
$this->shopBillingData = $this->createMock(InvoiceShopBillingDataInterface::class);
$this->order = $this->createMock(OrderInterface::class);
- $this->issuedAt = \DateTimeImmutable::createFromFormat('Y-m', '2019-01');
+ $issuedAt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2019-01-15 10:30:00');
+ self::assertNotFalse($issuedAt);
+ $this->issuedAt = $issuedAt;
+ }
- $this->lineItem->expects(self::once())
- ->method('setInvoice')
- ->with($this->isInstanceOf(Invoice::class));
+ #[Test]
+ public function it_implements_invoice_interface(): void
+ {
+ $invoice = $this->createInvoice();
- $this->taxItem->expects(self::once())
- ->method('setInvoice')
- ->with($this->isInstanceOf(Invoice::class));
+ self::assertInstanceOf(InvoiceInterface::class, $invoice);
+ }
+
+ #[Test]
+ public function it_implements_resource_interface(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertInstanceOf(ResourceInterface::class, $invoice);
+ }
+
+ #[Test]
+ public function it_returns_id(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame(self::ID, $invoice->id());
+ }
+
+ #[Test]
+ public function it_returns_id_via_get_id_method(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame(self::ID, $invoice->getId());
+ }
+
+ #[Test]
+ public function it_returns_invoice_number(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame(self::INVOICE_NUMBER, $invoice->number());
+ }
+
+ #[Test]
+ public function it_returns_order(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame($this->order, $invoice->order());
+ }
+
+ #[Test]
+ public function it_returns_cloned_issued_at_date(): void
+ {
+ $invoice = $this->createInvoice();
+
+ $issuedAt = $invoice->issuedAt();
+
+ self::assertEquals($this->issuedAt, $issuedAt);
+ self::assertNotSame($this->issuedAt, $issuedAt);
+ }
+
+ #[Test]
+ public function it_returns_billing_data(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame($this->billingData, $invoice->billingData());
+ }
+
+ #[Test]
+ public function it_returns_currency_code(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame(self::CURRENCY_CODE, $invoice->currencyCode());
+ }
+
+ #[Test]
+ public function it_returns_locale_code(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame(self::LOCALE_CODE, $invoice->localeCode());
+ }
+
+ #[Test]
+ public function it_returns_total(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame(self::TOTAL, $invoice->total());
+ }
+
+ #[Test]
+ public function it_returns_line_items(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertEquals(new ArrayCollection([$this->lineItem]), $invoice->lineItems());
+ }
- $this->invoice = new Invoice(
- '7903c83a-4c5e-4bcf-81d8-9dc304c6a353',
- $this->issuedAt->format('Y/m') . '/000000001',
+ #[Test]
+ public function it_returns_tax_items(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertEquals(new ArrayCollection([$this->taxItem]), $invoice->taxItems());
+ }
+
+ #[Test]
+ public function it_returns_channel(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame($this->channel, $invoice->channel());
+ }
+
+ #[Test]
+ public function it_returns_shop_billing_data(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame($this->shopBillingData, $invoice->shopBillingData());
+ }
+
+ #[Test]
+ public function it_returns_payment_state(): void
+ {
+ $invoice = $this->createInvoice();
+
+ self::assertSame(InvoiceInterface::PAYMENT_STATE_COMPLETED, $invoice->paymentState());
+ }
+
+ #[Test]
+ #[DataProvider('subtotalDataProvider')]
+ public function it_calculates_subtotal_from_line_items(array $lineItemSubtotals, int $expectedSubtotal): void
+ {
+ $lineItems = [];
+ foreach ($lineItemSubtotals as $subtotal) {
+ $lineItem = $this->createMock(LineItemInterface::class);
+ $lineItem->method('subtotal')->willReturn($subtotal);
+ $lineItem->expects(self::once())->method('setInvoice');
+ $lineItems[] = $lineItem;
+ }
+
+ $invoice = new Invoice(
+ self::ID,
+ self::INVOICE_NUMBER,
$this->order,
$this->issuedAt,
$this->billingData,
- 'USD',
- 'en_US',
- 10300,
- new ArrayCollection([$this->lineItem]),
- new ArrayCollection([$this->taxItem]),
+ self::CURRENCY_CODE,
+ self::LOCALE_CODE,
+ self::TOTAL,
+ new ArrayCollection($lineItems),
+ new ArrayCollection([]),
$this->channel,
InvoiceInterface::PAYMENT_STATE_COMPLETED,
$this->shopBillingData,
);
+
+ self::assertSame($expectedSubtotal, $invoice->subtotal());
}
#[Test]
- public function it_implements_invoice_interface(): void
+ #[DataProvider('taxesTotalDataProvider')]
+ public function it_calculates_taxes_total_from_line_items(array $lineItemTaxes, int $expectedTaxesTotal): void
{
- self::assertInstanceOf(InvoiceInterface::class, $this->invoice);
+ $lineItems = [];
+ foreach ($lineItemTaxes as $taxTotal) {
+ $lineItem = $this->createMock(LineItemInterface::class);
+ $lineItem->method('taxTotal')->willReturn($taxTotal);
+ $lineItem->expects(self::once())->method('setInvoice');
+ $lineItems[] = $lineItem;
+ }
+
+ $invoice = new Invoice(
+ self::ID,
+ self::INVOICE_NUMBER,
+ $this->order,
+ $this->issuedAt,
+ $this->billingData,
+ self::CURRENCY_CODE,
+ self::LOCALE_CODE,
+ self::TOTAL,
+ new ArrayCollection($lineItems),
+ new ArrayCollection([]),
+ $this->channel,
+ InvoiceInterface::PAYMENT_STATE_COMPLETED,
+ $this->shopBillingData,
+ );
+
+ self::assertSame($expectedTaxesTotal, $invoice->taxesTotal());
}
#[Test]
- public function it_implements_resource_interface(): void
+ #[DataProvider('pdfSentStatusProvider')]
+ public function it_returns_pdf_sent_status(bool $pdfSent): void
{
- self::assertInstanceOf(ResourceInterface::class, $this->invoice);
+ $invoice = $this->createInvoice($pdfSent);
+
+ self::assertSame($pdfSent, $invoice->isPdfSent());
}
#[Test]
- public function it_has_data(): void
+ public function it_allows_setting_pdf_sent_status(): void
+ {
+ $invoice = $this->createInvoice(false);
+
+ self::assertFalse($invoice->isPdfSent());
+
+ $invoice->setPdfSent(true);
+
+ self::assertTrue($invoice->isPdfSent());
+
+ $invoice->setPdfSent(false);
+
+ self::assertFalse($invoice->isPdfSent());
+ }
+
+ public static function subtotalDataProvider(): array
+ {
+ return [
+ 'single line item' => [[1000], 1000],
+ 'multiple line items' => [[1000, 2000, 1500], 4500],
+ 'empty line items' => [[], 0],
+ ];
+ }
+
+ public static function taxesTotalDataProvider(): array
{
- self::assertSame('7903c83a-4c5e-4bcf-81d8-9dc304c6a353', $this->invoice->id());
- self::assertSame('2019/01/000000001', $this->invoice->number());
- self::assertSame($this->order, $this->invoice->order());
- self::assertSame($this->billingData, $this->invoice->billingData());
- self::assertSame('USD', $this->invoice->currencyCode());
- self::assertSame('en_US', $this->invoice->localeCode());
- self::assertSame(10300, $this->invoice->total());
- self::assertEquals(new ArrayCollection([$this->lineItem]), $this->invoice->lineItems());
- $this->assertEquals(new ArrayCollection([$this->taxItem]), $this->invoice->taxItems());
- $this->assertSame($this->channel, $this->invoice->channel());
- $this->assertSame($this->shopBillingData, $this->invoice->shopBillingData());
+ return [
+ 'single line item with tax' => [[200], 200],
+ 'multiple line items with taxes' => [[200, 300, 150], 650],
+ 'empty line items' => [[], 0],
+ ];
+ }
+
+ public static function pdfSentStatusProvider(): array
+ {
+ return [
+ 'pdf not sent by default' => [false],
+ 'pdf sent' => [true],
+ ];
+ }
+
+ private function createInvoice(bool $pdfSent = false): Invoice
+ {
+ $this->lineItem
+ ->expects(self::once())
+ ->method('setInvoice')
+ ->with($this->isInstanceOf(Invoice::class));
+
+ $this->taxItem
+ ->expects(self::once())
+ ->method('setInvoice')
+ ->with($this->isInstanceOf(Invoice::class));
+
+ return new Invoice(
+ self::ID,
+ self::INVOICE_NUMBER,
+ $this->order,
+ $this->issuedAt,
+ $this->billingData,
+ self::CURRENCY_CODE,
+ self::LOCALE_CODE,
+ self::TOTAL,
+ new ArrayCollection([$this->lineItem]),
+ new ArrayCollection([$this->taxItem]),
+ $this->channel,
+ InvoiceInterface::PAYMENT_STATE_COMPLETED,
+ $this->shopBillingData,
+ $pdfSent,
+ );
}
}
diff --git a/tests/Unit/Provider/InvoiceFileProviderTest.php b/tests/Unit/Provider/InvoiceFileProviderTest.php
index 541df8a7..4893c387 100644
--- a/tests/Unit/Provider/InvoiceFileProviderTest.php
+++ b/tests/Unit/Provider/InvoiceFileProviderTest.php
@@ -16,10 +16,12 @@
use Gaufrette\Exception\FileNotFound;
use Gaufrette\File;
use Gaufrette\FilesystemInterface;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\InvoicingPlugin\Entity\InvoiceInterface;
+use Sylius\InvoicingPlugin\Exception\InvoiceFileGenerationFailedException;
use Sylius\InvoicingPlugin\Generator\InvoiceFileNameGeneratorInterface;
use Sylius\InvoicingPlugin\Generator\InvoicePdfFileGeneratorInterface;
use Sylius\InvoicingPlugin\Manager\InvoiceFileManagerInterface;
@@ -29,6 +31,12 @@
final class InvoiceFileProviderTest extends TestCase
{
+ private const INVOICE_FILENAME = 'invoice_2024_11_0001.pdf';
+
+ private const INVOICES_DIRECTORY = '/path/to/invoices';
+
+ private const PDF_CONTENT = 'PDF_CONTENT';
+
private InvoiceFileNameGeneratorInterface&MockObject $invoiceFileNameGenerator;
private FilesystemInterface&MockObject $filesystem;
@@ -37,6 +45,8 @@ final class InvoiceFileProviderTest extends TestCase
private InvoiceFileManagerInterface&MockObject $invoiceFileManager;
+ private InvoiceInterface&MockObject $invoice;
+
private InvoiceFileProvider $provider;
protected function setUp(): void
@@ -46,13 +56,14 @@ protected function setUp(): void
$this->filesystem = $this->createMock(FilesystemInterface::class);
$this->invoicePdfFileGenerator = $this->createMock(InvoicePdfFileGeneratorInterface::class);
$this->invoiceFileManager = $this->createMock(InvoiceFileManagerInterface::class);
+ $this->invoice = $this->createMock(InvoiceInterface::class);
$this->provider = new InvoiceFileProvider(
$this->invoiceFileNameGenerator,
$this->filesystem,
$this->invoicePdfFileGenerator,
$this->invoiceFileManager,
- '/path/to/invoices',
+ self::INVOICES_DIRECTORY,
);
}
@@ -63,72 +74,168 @@ public function it_implements_invoice_file_provider_interface(): void
}
#[Test]
- public function it_provides_invoice_file_for_invoice(): void
+ #[DataProvider('invoiceFileDataProvider')]
+ public function it_provides_existing_invoice_file_from_filesystem(string $fileName, string $content): void
{
- $invoice = $this->createMock(InvoiceInterface::class);
$invoiceFile = $this->createMock(File::class);
$this->invoiceFileNameGenerator
->expects(self::once())
->method('generateForPdf')
- ->with($invoice)
- ->willReturn('invoice.pdf');
+ ->with($this->invoice)
+ ->willReturn($fileName);
$this->filesystem
->expects(self::once())
->method('get')
- ->with('invoice.pdf')
+ ->with($fileName)
->willReturn($invoiceFile);
$invoiceFile
->expects(self::once())
->method('getContent')
- ->willReturn('CONTENT');
+ ->willReturn($content);
+
+ $this->invoicePdfFileGenerator
+ ->expects(self::never())
+ ->method('generate');
+
+ $this->invoiceFileManager
+ ->expects(self::never())
+ ->method('save');
- $result = $this->provider->provide($invoice);
+ $result = $this->provider->provide($this->invoice);
- $expected = new InvoicePdf('invoice.pdf', 'CONTENT');
- $expected->setFullPath('/path/to/invoices/invoice.pdf');
+ $expected = $this->createExpectedInvoicePdf($fileName, $content);
self::assertEquals($expected, $result);
}
#[Test]
- public function it_generates_invoice_if_it_does_not_exist_and_provides_it(): void
+ #[DataProvider('invoiceFileDataProvider')]
+ public function it_generates_and_saves_invoice_when_file_not_found(string $fileName, string $content): void
{
- $invoice = $this->createMock(InvoiceInterface::class);
-
$this->invoiceFileNameGenerator
->expects(self::once())
->method('generateForPdf')
- ->with($invoice)
- ->willReturn('invoice.pdf');
+ ->with($this->invoice)
+ ->willReturn($fileName);
$this->filesystem
->expects(self::once())
->method('get')
- ->with('invoice.pdf')
- ->willThrowException(new FileNotFound('invoice.pdf'));
+ ->with($fileName)
+ ->willThrowException(new FileNotFound($fileName));
- $invoicePdf = new InvoicePdf('invoice.pdf', 'CONTENT');
- $invoicePdf->setFullPath('/path/to/invoices/invoice.pdf');
+ $generatedInvoicePdf = new InvoicePdf($fileName, $content);
$this->invoicePdfFileGenerator
->expects(self::once())
->method('generate')
- ->with($invoice)
- ->willReturn($invoicePdf);
+ ->with($this->invoice)
+ ->willReturn($generatedInvoicePdf);
$this->invoiceFileManager
->expects(self::once())
->method('save')
- ->with($invoicePdf);
+ ->with($generatedInvoicePdf);
- $result = $this->provider->provide($invoice);
+ $result = $this->provider->provide($this->invoice);
- $expected = new InvoicePdf('invoice.pdf', 'CONTENT');
- $expected->setFullPath('/path/to/invoices/invoice.pdf');
+ $expected = $this->createExpectedInvoicePdf($fileName, $content);
self::assertEquals($expected, $result);
}
+
+ #[Test]
+ #[DataProvider('generationExceptionProvider')]
+ public function it_throws_invoice_file_generation_failed_exception_when_generation_fails(\Throwable $exception): void
+ {
+ $this->expectException(InvoiceFileGenerationFailedException::class);
+
+ $this->expectInvoiceFileNameGeneration();
+
+ $this->filesystem
+ ->expects(self::once())
+ ->method('get')
+ ->with(self::INVOICE_FILENAME)
+ ->willThrowException(new FileNotFound(self::INVOICE_FILENAME));
+
+ $this->invoicePdfFileGenerator
+ ->expects(self::once())
+ ->method('generate')
+ ->with($this->invoice)
+ ->willThrowException($exception);
+
+ $this->invoiceFileManager
+ ->expects(self::never())
+ ->method('save');
+
+ $this->provider->provide($this->invoice);
+ }
+
+ #[Test]
+ public function it_throws_invoice_file_generation_failed_exception_when_save_fails(): void
+ {
+ $this->expectException(InvoiceFileGenerationFailedException::class);
+
+ $this->expectInvoiceFileNameGeneration();
+
+ $this->filesystem
+ ->expects(self::once())
+ ->method('get')
+ ->with(self::INVOICE_FILENAME)
+ ->willThrowException(new FileNotFound(self::INVOICE_FILENAME));
+
+ $generatedInvoicePdf = new InvoicePdf(self::INVOICE_FILENAME, self::PDF_CONTENT);
+
+ $this->invoicePdfFileGenerator
+ ->expects(self::once())
+ ->method('generate')
+ ->with($this->invoice)
+ ->willReturn($generatedInvoicePdf);
+
+ $this->invoiceFileManager
+ ->expects(self::once())
+ ->method('save')
+ ->with($generatedInvoicePdf)
+ ->willThrowException(new \RuntimeException('Failed to save file'));
+
+ $this->provider->provide($this->invoice);
+ }
+
+ public static function invoiceFileDataProvider(): array
+ {
+ return [
+ 'standard filename and content' => ['invoice_2024_11_0001.pdf', 'PDF_CONTENT'],
+ 'custom filename' => ['custom_invoice.pdf', 'CUSTOM_CONTENT'],
+ 'filename with special chars' => ['invoice-special_#123.pdf', 'SPECIAL_CONTENT'],
+ ];
+ }
+
+ public static function generationExceptionProvider(): array
+ {
+ return [
+ 'RuntimeException' => [new \RuntimeException('Failed to generate PDF')],
+ 'InvalidArgumentException' => [new \InvalidArgumentException('Invalid template')],
+ 'LogicException' => [new \LogicException('Logic error in generation')],
+ ];
+ }
+
+ private function expectInvoiceFileNameGeneration(): void
+ {
+ $this->invoiceFileNameGenerator
+ ->expects(self::once())
+ ->method('generateForPdf')
+ ->with($this->invoice)
+ ->willReturn(self::INVOICE_FILENAME);
+ }
+
+ private function createExpectedInvoicePdf(string $fileName = self::INVOICE_FILENAME, string $content = self::PDF_CONTENT): InvoicePdf
+ {
+ $invoicePdf = new InvoicePdf($fileName, $content);
+ $invoicePdf->setFullPath(self::INVOICES_DIRECTORY . '/' . $fileName);
+
+ return $invoicePdf;
+ }
}