diff --git a/.gitignore b/.gitignore index 012f2eeb..830c0a03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/var/ /vendor/ /node_modules/ /composer.lock diff --git a/CHANGELOG-2.0.md b/CHANGELOG-2.0.md index c68dbd6f..22127eec 100644 --- a/CHANGELOG-2.0.md +++ b/CHANGELOG-2.0.md @@ -1,5 +1,10 @@ # CHANGELOG +### v2.0.3 (2025-10-21) + +- [#373](https://github.com/Sylius/InvoicingPlugin/pull/373) Add configurable invoice sequence scope (`monthly`/`annually`/`global`) + via SYLIUS_INVOICING_SEQUENCE_SCOPE ENV ([@tomkalon](https://github.com/tomkalon)) + ### v2.0.2 (2025-07-03) - [#373](https://github.com/Sylius/InvoicingPlugin/pull/373) Add sylius/test-application ([@Wojdylak](https://github.com/Wojdylak)) diff --git a/CHANGELOG-3.0.md b/CHANGELOG-3.0.md new file mode 100644 index 00000000..e77d59b0 --- /dev/null +++ b/CHANGELOG-3.0.md @@ -0,0 +1,13 @@ +# CHANGELOG + +### v3.0.0 (2025-10-24) + +- [#397](https://github.com/Sylius/InvoicingPlugin/pull/397) Isolate plugin messaging: Introduce dedicated event & command buses for InvoicingPlugin ([@tomkalon](https://github.com/tomkalon)) +- [#398](https://github.com/Sylius/InvoicingPlugin/pull/398) Persisted PDF path & file flow + - `Invoice`: added `path` field (UNIQUE). + - `InvoiceFactory`: inject `InvoiceFileNameGeneratorInterface`; use `generateForPdf($number)` to set `path` on creation. + - `InvoiceFileProvider`: removed dependency on file-name generator; added `%sylius_invoicing.pdf_generator.enabled%`; now relies on `Invoice::path()`. + - `InvoiceCreator`: removed `InvoicePdfFileGeneratorInterface` and `InvoiceFileManagerInterface`; PDF is no longer generated on invoice creation—it's generated lazily on first download/provide. + - `InvoiceFileNameGeneratorInterface::generateForPdf()` now accepts `string $invoiceNumber` instead of `InvoiceInterface`. + - `InvoiceFileNameGeneratorInterface::generateForPdf()` can prefix filenames based on `SYLIUS_INVOICING_SEQUENCE_SCOPE` (`global` – default, `monthly`, `annually`). + - `InvoicePdfFileGenerator`: removed `InvoiceFileNameGeneratorInterface` from constructor; filename is taken from `Invoice::path()`; update DI to drop the generator argument. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 052c0a91..667c4aa9 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -1,3 +1,17 @@ +# UPGRADE FROM 2.0 TO 2.1 + +## Changes + +1. Added support for configurable invoice sequence scoping via the SYLIUS_INVOICING_SEQUENCE_SCOPE environment variable: + +- monthly: resets invoice numbering each month +- annually: resets invoice numbering each year +- global or unset (default): uses a single global sequence (as previously) + +## Deprecations + +1. Not passing the $scope argument (of type InvoiceSequenceScopeEnum) to the constructor of SequentialInvoiceNumberGenerator is deprecated and will be required starting from version 3.0. + # UPGRADE FROM 1.X TO 2.0 1. Support for Sylius 2.0 has been added, it is now the recommended Sylius version to use with InvoicingPlugin. diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md new file mode 100644 index 00000000..e7532d5c --- /dev/null +++ b/UPGRADE-3.0.md @@ -0,0 +1,104 @@ +# UPGRADE FROM 2.2 TO 3.0 + +## Changes + +1. Persisted PDF path on `Invoice`: + +- Added to `Invoice` new `path` field (unique) storing the final PDF location (e.g. annually/2025_10_000000001.pdf). + +2. Filename generation moved from `InvoiceCreator` to `InvoiceFactory`. + +`InvoiceCreator` no longer generates PDFs at creation time. + +PDFs are generated on first provide/download via the provider. + +```xml + + %sylius_invoicing.model.invoice.class% + ++ + +``` + +```xml + + + + +- +- +- %sylius_invoicing.pdf_generator.enabled% + +``` + +3. `InvoiceFactory` now depends on `InvoiceFileNameGeneratorInterface`. +```xml + + %sylius_invoicing.model.invoice.class% + ++ + +``` + +On creation, it calls: +```php +$fileName = $invoiceFileNameGenerator->generateForPdf($number); +``` +and passes it to the `Invoice` constructor as `$path`. + +4. `InvoiceFileProvider` is now the primary orchestrator of PDF generation + +Removed `InvoiceFileNameGeneratorInterface` from `InvoiceFileProvider`. + +Added `sylius_invoicing.pdf_generator.enabled` parameter to constructor. + +```xml + +- + + + + %sylius_invoicing.invoice_save_path% ++ %sylius_invoicing.pdf_generator.enabled% + +``` + +5. `InvoiceFileNameGenerator` signature & scoping + +BC break: `generateForPdf()` now accepts string $invoiceNumber (not `InvoiceInterface`). + +```php +// before: +public function generateForPdf(InvoiceInterface $invoice): string; + +// after: +public function generateForPdf(string $invoiceNumber): string; +``` + +6. Can prefix filenames based on `SYLIUS_INVOICING_SEQUENCE_SCOPE`: + +>global (default): no prefix +> +>monthly: monthly/… +> +>annually: annually/… + +7. `InvoicePdfFileGenerator` simplified: + +- Removed dependency on InvoiceFileNameGeneratorInterface. + +- Uses `Invoice::path()` as the filename: + +```xml + + + +- + @SyliusInvoicingPlugin/shared/download/pdf.html.twig + %sylius_invoicing.template.logo_file% + +``` + +```php +$filename = $invoice->path(); +``` diff --git a/config/config.yaml b/config/config.yaml index 87569df3..47fe4ced 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -5,6 +5,8 @@ imports: parameters: sylius_invoicing.invoice_save_path: "%kernel.project_dir%/private/invoices/" sylius_invoicing.filesystem_adapter.invoice: "sylius_invoicing_invoice" + sylius_invoicing.sequence_scope: '%env(default::SYLIUS_INVOICING_SEQUENCE_SCOPE)%' + env(SYLIUS_INVOICING_SEQUENCE_SCOPE): 'global' sylius_invoicing: pdf_generator: diff --git a/config/doctrine/Invoice.orm.xml b/config/doctrine/Invoice.orm.xml index c754a98f..5603b2d6 100644 --- a/config/doctrine/Invoice.orm.xml +++ b/config/doctrine/Invoice.orm.xml @@ -10,6 +10,7 @@ + diff --git a/config/doctrine/InvoiceSequence.orm.xml b/config/doctrine/InvoiceSequence.orm.xml index ed04a5e4..4d6591d2 100644 --- a/config/doctrine/InvoiceSequence.orm.xml +++ b/config/doctrine/InvoiceSequence.orm.xml @@ -11,6 +11,9 @@ + + + diff --git a/config/services.xml b/config/services.xml index 47ea616a..f8c972f2 100644 --- a/config/services.xml +++ b/config/services.xml @@ -51,6 +51,7 @@ %sylius_invoicing.model.invoice.class% + @@ -60,11 +61,11 @@ - %sylius_invoicing.invoice_save_path% + %sylius_invoicing.pdf_generator.enabled% diff --git a/config/services/generators.xml b/config/services/generators.xml index cfca0642..b5cd3d80 100644 --- a/config/services/generators.xml +++ b/config/services/generators.xml @@ -22,6 +22,7 @@ + %sylius_invoicing.sequence_scope% @@ -38,16 +39,14 @@ - + + %sylius_invoicing.sequence_scope% + - @SyliusInvoicingPlugin/shared/download/pdf.html.twig %sylius_invoicing.template.logo_file% @@ -57,9 +56,6 @@ - - - %sylius_invoicing.pdf_generator.enabled% diff --git a/src/Creator/InvoiceCreator.php b/src/Creator/InvoiceCreator.php index 15b18b96..91c97008 100644 --- a/src/Creator/InvoiceCreator.php +++ b/src/Creator/InvoiceCreator.php @@ -13,15 +13,12 @@ namespace Sylius\InvoicingPlugin\Creator; -use Doctrine\ORM\Exception\ORMException; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Repository\OrderRepositoryInterface; use Sylius\InvoicingPlugin\Doctrine\ORM\InvoiceRepositoryInterface; use Sylius\InvoicingPlugin\Entity\InvoiceInterface; use Sylius\InvoicingPlugin\Exception\InvoiceAlreadyGenerated; use Sylius\InvoicingPlugin\Generator\InvoiceGeneratorInterface; -use Sylius\InvoicingPlugin\Generator\InvoicePdfFileGeneratorInterface; -use Sylius\InvoicingPlugin\Manager\InvoiceFileManagerInterface; final class InvoiceCreator implements InvoiceCreatorInterface { @@ -29,9 +26,6 @@ public function __construct( private readonly InvoiceRepositoryInterface $invoiceRepository, private readonly OrderRepositoryInterface $orderRepository, private readonly InvoiceGeneratorInterface $invoiceGenerator, - private readonly InvoicePdfFileGeneratorInterface $invoicePdfFileGenerator, - private readonly InvoiceFileManagerInterface $invoiceFileManager, - private readonly bool $hasEnabledPdfFileGenerator = true, ) { } @@ -49,19 +43,6 @@ public function __invoke(string $orderNumber, \DateTimeInterface $dateTime): voi $invoice = $this->invoiceGenerator->generateForOrder($order, $dateTime); - if (!$this->hasEnabledPdfFileGenerator) { - $this->invoiceRepository->add($invoice); - - return; - } - - $invoicePdf = $this->invoicePdfFileGenerator->generate($invoice); - $this->invoiceFileManager->save($invoicePdf); - - try { - $this->invoiceRepository->add($invoice); - } catch (ORMException) { - $this->invoiceFileManager->remove($invoicePdf); - } + $this->invoiceRepository->add($invoice); } } diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index 969e6bd8..46ce1615 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 string $path, ) { $this->issuedAt = clone $issuedAt; @@ -143,4 +144,9 @@ public function paymentState(): string { return $this->paymentState; } + + public function path(): string + { + return $this->path; + } } diff --git a/src/Entity/InvoiceInterface.php b/src/Entity/InvoiceInterface.php index 0ddede9a..e8ef1e66 100644 --- a/src/Entity/InvoiceInterface.php +++ b/src/Entity/InvoiceInterface.php @@ -53,4 +53,6 @@ public function channel(): ChannelInterface; public function shopBillingData(): InvoiceShopBillingDataInterface; public function paymentState(): string; + + public function path(): string; } diff --git a/src/Entity/InvoiceSequence.php b/src/Entity/InvoiceSequence.php index 064eb8e8..a8ec4226 100644 --- a/src/Entity/InvoiceSequence.php +++ b/src/Entity/InvoiceSequence.php @@ -13,6 +13,8 @@ namespace Sylius\InvoicingPlugin\Entity; +use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; + /** @final */ class InvoiceSequence implements InvoiceSequenceInterface { @@ -23,6 +25,12 @@ class InvoiceSequence implements InvoiceSequenceInterface protected ?int $version = 1; + protected ?InvoiceSequenceScopeEnum $type = null; + + protected ?int $year = null; + + protected ?int $month = null; + /** @return mixed */ public function getId() { @@ -48,4 +56,34 @@ public function setVersion(?int $version): void { $this->version = $version; } + + public function getType(): ?InvoiceSequenceScopeEnum + { + return $this->type; + } + + public function setType(?InvoiceSequenceScopeEnum $type): void + { + $this->type = $type; + } + + public function getYear(): ?int + { + return $this->year; + } + + public function setYear(?int $year): void + { + $this->year = $year; + } + + public function getMonth(): ?int + { + return $this->month; + } + + public function setMonth(?int $month): void + { + $this->month = $month; + } } diff --git a/src/Entity/InvoiceSequenceInterface.php b/src/Entity/InvoiceSequenceInterface.php index a263fe9a..51efcd57 100644 --- a/src/Entity/InvoiceSequenceInterface.php +++ b/src/Entity/InvoiceSequenceInterface.php @@ -15,10 +15,23 @@ use Sylius\Component\Resource\Model\ResourceInterface; use Sylius\Component\Resource\Model\VersionedInterface; +use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; interface InvoiceSequenceInterface extends ResourceInterface, VersionedInterface { public function getIndex(): int; public function incrementIndex(): void; + + public function getType(): ?InvoiceSequenceScopeEnum; + + public function setType(?InvoiceSequenceScopeEnum $type): void; + + public function getYear(): ?int; + + public function getMonth(): ?int; + + public function setYear(?int $year): void; + + public function setMonth(?int $month): void; } diff --git a/src/Enum/InvoiceSequenceScopeEnum.php b/src/Enum/InvoiceSequenceScopeEnum.php new file mode 100644 index 00000000..110876b9 --- /dev/null +++ b/src/Enum/InvoiceSequenceScopeEnum.php @@ -0,0 +1,21 @@ +invoiceFileNameGenerator->generateForPdf($number); + /** @var InvoiceInterface $invoice */ $invoice = new $this->className( $id, @@ -63,6 +67,7 @@ public function createForData( $channel, $paymentState, $shopBillingData ?? $this->invoiceShopBillingDataFactory->createNew(), + $fileName, ); Assert::isInstanceOf($invoice, InvoiceInterface::class); diff --git a/src/Generator/InvoiceFileNameGenerator.php b/src/Generator/InvoiceFileNameGenerator.php index 5ab62abf..47e8673e 100644 --- a/src/Generator/InvoiceFileNameGenerator.php +++ b/src/Generator/InvoiceFileNameGenerator.php @@ -13,14 +13,28 @@ namespace Sylius\InvoicingPlugin\Generator; -use Sylius\InvoicingPlugin\Entity\InvoiceInterface; +use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; final class InvoiceFileNameGenerator implements InvoiceFileNameGeneratorInterface { private const PDF_FILE_EXTENSION = '.pdf'; - public function generateForPdf(InvoiceInterface $invoice): string + public function __construct( + private readonly ?string $scope = null, + ) { + } + + public function generateForPdf(string $invoiceNumber): string { - return str_replace('/', '_', $invoice->number()) . self::PDF_FILE_EXTENSION; + $scope = InvoiceSequenceScopeEnum::tryFrom($this->scope ?? '') ?? InvoiceSequenceScopeEnum::GLOBAL; + $prefix = $scope->value . '/'; + + if ($scope === InvoiceSequenceScopeEnum::GLOBAL) { + $prefix = ''; + } + + $fileName = str_replace('/', '_', $invoiceNumber) . self::PDF_FILE_EXTENSION; + + return $prefix . $fileName; } } diff --git a/src/Generator/InvoiceFileNameGeneratorInterface.php b/src/Generator/InvoiceFileNameGeneratorInterface.php index f57fe7f5..60834eab 100644 --- a/src/Generator/InvoiceFileNameGeneratorInterface.php +++ b/src/Generator/InvoiceFileNameGeneratorInterface.php @@ -13,9 +13,7 @@ namespace Sylius\InvoicingPlugin\Generator; -use Sylius\InvoicingPlugin\Entity\InvoiceInterface; - interface InvoiceFileNameGeneratorInterface { - public function generateForPdf(InvoiceInterface $invoice): string; + public function generateForPdf(string $invoiceNumber): string; } diff --git a/src/Generator/InvoicePdfFileGenerator.php b/src/Generator/InvoicePdfFileGenerator.php index b211caef..82679469 100644 --- a/src/Generator/InvoicePdfFileGenerator.php +++ b/src/Generator/InvoicePdfFileGenerator.php @@ -22,7 +22,6 @@ final class InvoicePdfFileGenerator implements InvoicePdfFileGeneratorInterface public function __construct( private readonly TwigToPdfGeneratorInterface $twigToPdfGenerator, private readonly FileLocatorInterface $fileLocator, - private readonly InvoiceFileNameGeneratorInterface $invoiceFileNameGenerator, private readonly string $template, private readonly string $invoiceLogoPath, ) { @@ -30,7 +29,7 @@ public function __construct( public function generate(InvoiceInterface $invoice): InvoicePdf { - $filename = $this->invoiceFileNameGenerator->generateForPdf($invoice); + $filename = $invoice->path(); $pdf = $this->twigToPdfGenerator->generate( $this->template, diff --git a/src/Generator/SequentialInvoiceNumberGenerator.php b/src/Generator/SequentialInvoiceNumberGenerator.php index 89e41349..b5754949 100644 --- a/src/Generator/SequentialInvoiceNumberGenerator.php +++ b/src/Generator/SequentialInvoiceNumberGenerator.php @@ -18,6 +18,7 @@ use Sylius\Component\Resource\Factory\FactoryInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; use Sylius\InvoicingPlugin\Entity\InvoiceSequenceInterface; +use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; use Symfony\Component\Clock\ClockInterface; final class SequentialInvoiceNumberGenerator implements InvoiceNumberGenerator @@ -29,7 +30,17 @@ public function __construct( private readonly ClockInterface $clock, private readonly int $startNumber = 1, private readonly int $numberLength = 9, + private readonly ?string $scope = null, ) { + if (null === $this->scope) { + trigger_deprecation( + 'sylius/invoicing-plugin', + '2.1', + 'Not passing the "%s" argument to "%s::__construct()" is deprecated and will be required in version 3.0. Pass a valid scope explicitly (e.g. "monthly", "annually", or "global").', + 'scope', + self::class, + ); + } } public function generate(): string @@ -56,15 +67,47 @@ private function generateNumber(int $index): string private function getSequence(): InvoiceSequenceInterface { - /** @var InvoiceSequenceInterface $sequence */ - $sequence = $this->sequenceRepository->findOneBy([]); - - if (null != $sequence) { + $now = $this->clock->now(); + $scope = InvoiceSequenceScopeEnum::tryFrom($this->scope ?? '') ?? InvoiceSequenceScopeEnum::GLOBAL; + + $criteria = match ($scope) { + InvoiceSequenceScopeEnum::MONTHLY => [ + 'year' => (int) $now->format('Y'), + 'month' => (int) $now->format('m'), + 'type' => $scope, + ], + InvoiceSequenceScopeEnum::ANNUALLY => [ + 'year' => (int) $now->format('Y'), + 'type' => $scope, + ], + InvoiceSequenceScopeEnum::GLOBAL => [ + 'year' => null, + 'month' => null, + ], + }; + + /** @var InvoiceSequenceInterface|null $sequence */ + $sequence = $this->sequenceRepository->findOneBy($criteria); + + if (null !== $sequence) { return $sequence; } /** @var InvoiceSequenceInterface $sequence */ $sequence = $this->sequenceFactory->createNew(); + + if (isset($criteria['year'])) { + $sequence->setYear($criteria['year']); + } + + if (isset($criteria['month'])) { + $sequence->setMonth($criteria['month']); + } + + if (isset($criteria['type'])) { + $sequence->setType($criteria['type']); + } + $this->sequenceManager->persist($sequence); return $sequence; diff --git a/src/Migrations/Version20251021074051.php b/src/Migrations/Version20251021074051.php new file mode 100644 index 00000000..c8933206 --- /dev/null +++ b/src/Migrations/Version20251021074051.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE sylius_invoicing_plugin_sequence ADD year INT DEFAULT NULL, ADD month INT DEFAULT NULL, ADD type VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE sylius_invoicing_plugin_sequence DROP year, DROP month, DROP type'); + } +} diff --git a/src/Migrations/Version20251023082457.php b/src/Migrations/Version20251023082457.php new file mode 100644 index 00000000..167e8f86 --- /dev/null +++ b/src/Migrations/Version20251023082457.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE sylius_invoicing_plugin_invoice ADD path VARCHAR(255) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_3AA279BFB548B0F ON sylius_invoicing_plugin_invoice (path)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX UNIQ_3AA279BFB548B0F ON sylius_invoicing_plugin_invoice'); + $this->addSql('ALTER TABLE sylius_invoicing_plugin_invoice DROP path'); + } +} diff --git a/src/Provider/InvoiceFileProvider.php b/src/Provider/InvoiceFileProvider.php index 711ce694..480fc6f2 100644 --- a/src/Provider/InvoiceFileProvider.php +++ b/src/Provider/InvoiceFileProvider.php @@ -16,7 +16,6 @@ use Gaufrette\Exception\FileNotFound; use Gaufrette\FilesystemInterface; use Sylius\InvoicingPlugin\Entity\InvoiceInterface; -use Sylius\InvoicingPlugin\Generator\InvoiceFileNameGeneratorInterface; use Sylius\InvoicingPlugin\Generator\InvoicePdfFileGeneratorInterface; use Sylius\InvoicingPlugin\Manager\InvoiceFileManagerInterface; use Sylius\InvoicingPlugin\Model\InvoicePdf; @@ -24,24 +23,27 @@ final 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 readonly bool $hasEnabledPdfFileGenerator = true, ) { } public function provide(InvoiceInterface $invoice): InvoicePdf { - $invoiceFileName = $this->invoiceFileNameGenerator->generateForPdf($invoice); + $invoiceFileName = $invoice->path(); try { $invoiceFile = $this->filesystem->get($invoiceFileName); $invoicePdf = new InvoicePdf($invoiceFileName, $invoiceFile->getContent()); } catch (FileNotFound) { $invoicePdf = $this->invoicePdfFileGenerator->generate($invoice); - $this->invoiceFileManager->save($invoicePdf); + + if ($this->hasEnabledPdfFileGenerator) { + $this->invoiceFileManager->save($invoicePdf); + } } $invoicePdf->setFullPath($this->invoicesDirectory . '/' . $invoiceFileName); diff --git a/src/Ui/Action/DownloadInvoiceAction.php b/src/Ui/Action/DownloadInvoiceAction.php index 4f32e3bf..2575730e 100644 --- a/src/Ui/Action/DownloadInvoiceAction.php +++ b/src/Ui/Action/DownloadInvoiceAction.php @@ -49,8 +49,10 @@ public function __invoke(string $id): Response $invoiceFile = $this->invoiceFilePathProvider->provide($invoice); $response = new Response($invoiceFile->content(), Response::HTTP_OK, ['Content-Type' => 'application/pdf']); + $filename = basename($invoiceFile->filename()); + $response->headers->add([ - 'Content-Disposition' => $response->headers->makeDisposition('attachment', $invoiceFile->filename()), + 'Content-Disposition' => $response->headers->makeDisposition('attachment', $filename), ]); return $response; diff --git a/tests/Behat/Context/Application/ManagingInvoicesContext.php b/tests/Behat/Context/Application/ManagingInvoicesContext.php index 765d8128..01996f16 100644 --- a/tests/Behat/Context/Application/ManagingInvoicesContext.php +++ b/tests/Behat/Context/Application/ManagingInvoicesContext.php @@ -30,7 +30,7 @@ public function theInvoiceForOrderShouldBeSavedOnTheServer(OrderInterface $order { /** @var InvoiceInterface $invoice */ $invoice = $this->invoiceRepository->findOneByOrder($order); - $filePath = $this->invoicesSavePath.'/'.str_replace('/', '_', $invoice->number()).'.pdf'; + $filePath = $this->invoicesSavePath.'/'.$invoice->path(); Assert::true(file_exists($filePath)); } diff --git a/tests/TestApplication/.env b/tests/TestApplication/.env index 657afa80..a055682d 100644 --- a/tests/TestApplication/.env +++ b/tests/TestApplication/.env @@ -9,3 +9,5 @@ WKHTMLTOPDF_PATH=/usr/local/bin/wkhtmltopdf ###< knplabs/knp-snappy-bundle ### TEST_SYLIUS_INVOICING_PDF_GENERATION_DISABLED=false + +SYLIUS_INVOICING_SEQUENCE_SCOPE='monthly' diff --git a/tests/Unit/Creator/InvoiceCreatorTest.php b/tests/Unit/Creator/InvoiceCreatorTest.php index deafe858..7edfc1b0 100644 --- a/tests/Unit/Creator/InvoiceCreatorTest.php +++ b/tests/Unit/Creator/InvoiceCreatorTest.php @@ -13,7 +13,6 @@ namespace Tests\Sylius\InvoicingPlugin\Unit\Creator; -use Doctrine\ORM\EntityNotFoundException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -25,9 +24,6 @@ use Sylius\InvoicingPlugin\Entity\InvoiceInterface; use Sylius\InvoicingPlugin\Exception\InvoiceAlreadyGenerated; use Sylius\InvoicingPlugin\Generator\InvoiceGeneratorInterface; -use Sylius\InvoicingPlugin\Generator\InvoicePdfFileGeneratorInterface; -use Sylius\InvoicingPlugin\Manager\InvoiceFileManagerInterface; -use Sylius\InvoicingPlugin\Model\InvoicePdf; final class InvoiceCreatorTest extends TestCase { @@ -37,10 +33,6 @@ final class InvoiceCreatorTest extends TestCase private InvoiceGeneratorInterface&MockObject $invoiceGenerator; - private InvoicePdfFileGeneratorInterface&MockObject $invoicePdfFileGenerator; - - private InvoiceFileManagerInterface&MockObject $invoiceFileManager; - private InvoiceCreator $creator; protected function setUp(): void @@ -49,15 +41,11 @@ protected function setUp(): void $this->invoiceRepository = $this->createMock(InvoiceRepositoryInterface::class); $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); $this->invoiceGenerator = $this->createMock(InvoiceGeneratorInterface::class); - $this->invoicePdfFileGenerator = $this->createMock(InvoicePdfFileGeneratorInterface::class); - $this->invoiceFileManager = $this->createMock(InvoiceFileManagerInterface::class); $this->creator = new InvoiceCreator( $this->invoiceRepository, $this->orderRepository, $this->invoiceGenerator, - $this->invoicePdfFileGenerator, - $this->invoiceFileManager, ); } @@ -72,7 +60,6 @@ public function it_creates_invoice_for_order(): void { $order = $this->createMock(OrderInterface::class); $invoice = $this->createMock(InvoiceInterface::class); - $invoicePdf = new InvoicePdf('invoice.pdf', 'CONTENT'); $invoiceDateTime = new \DateTimeImmutable('2019-02-25'); $this->orderRepository @@ -93,17 +80,6 @@ public function it_creates_invoice_for_order(): void ->with($order, $invoiceDateTime) ->willReturn($invoice); - $this->invoicePdfFileGenerator - ->expects(self::once()) - ->method('generate') - ->with($invoice) - ->willReturn($invoicePdf); - - $this->invoiceFileManager - ->expects(self::once()) - ->method('save') - ->with($invoicePdf); - $this->invoiceRepository ->expects(self::once()) ->method('add') @@ -112,107 +88,6 @@ public function it_creates_invoice_for_order(): void ($this->creator)('0000001', $invoiceDateTime); } - #[Test] - public function it_creates_invoice_without_generating_pdf_file(): void - { - $creator = new InvoiceCreator( - $this->invoiceRepository, - $this->orderRepository, - $this->invoiceGenerator, - $this->invoicePdfFileGenerator, - $this->invoiceFileManager, - false, - ); - - $order = $this->createMock(OrderInterface::class); - $invoice = $this->createMock(InvoiceInterface::class); - $invoiceDateTime = new \DateTimeImmutable('2019-02-25'); - - $this->orderRepository - ->expects(self::once()) - ->method('findOneByNumber') - ->with('0000001') - ->willReturn($order); - - $this->invoiceRepository - ->expects(self::once()) - ->method('findOneByOrder') - ->with($order) - ->willReturn(null); - - $this->invoiceGenerator - ->expects(self::once()) - ->method('generateForOrder') - ->with($order, $invoiceDateTime) - ->willReturn($invoice); - - $this->invoicePdfFileGenerator - ->expects($this->never()) - ->method('generate'); - - $this->invoiceFileManager - ->expects($this->never()) - ->method('save'); - - $this->invoiceRepository - ->expects(self::once()) - ->method('add') - ->with($invoice); - - $creator('0000001', $invoiceDateTime); - } - - #[Test] - public function it_removes_saved_invoice_file_if_database_update_fails(): void - { - $order = $this->createMock(OrderInterface::class); - $invoice = $this->createMock(InvoiceInterface::class); - $invoicePdf = new InvoicePdf('invoice.pdf', 'CONTENT'); - $invoiceDateTime = new \DateTimeImmutable('2019-02-25'); - - $this->orderRepository - ->expects(self::once()) - ->method('findOneByNumber') - ->with('0000001') - ->willReturn($order); - - $this->invoiceRepository - ->expects(self::once()) - ->method('findOneByOrder') - ->with($order) - ->willReturn(null); - - $this->invoiceGenerator - ->expects(self::once()) - ->method('generateForOrder') - ->with($order, $invoiceDateTime) - ->willReturn($invoice); - - $this->invoicePdfFileGenerator - ->expects(self::once()) - ->method('generate') - ->with($invoice) - ->willReturn($invoicePdf); - - $this->invoiceFileManager - ->expects(self::once()) - ->method('save') - ->with($invoicePdf); - - $this->invoiceRepository - ->expects(self::once()) - ->method('add') - ->with($invoice) - ->willThrowException(new EntityNotFoundException()); - - $this->invoiceFileManager - ->expects(self::once()) - ->method('remove') - ->with($invoicePdf); - - ($this->creator)('0000001', $invoiceDateTime); - } - #[Test] public function it_throws_an_exception_when_invoice_was_already_created_for_given_order(): void { diff --git a/tests/Unit/Entity/InvoiceTest.php b/tests/Unit/Entity/InvoiceTest.php index be210558..8b1193d6 100644 --- a/tests/Unit/Entity/InvoiceTest.php +++ b/tests/Unit/Entity/InvoiceTest.php @@ -78,6 +78,7 @@ protected function setUp(): void $this->channel, InvoiceInterface::PAYMENT_STATE_COMPLETED, $this->shopBillingData, + 'invoice.pdf', ); } @@ -98,6 +99,7 @@ public function it_has_data(): void { self::assertSame('7903c83a-4c5e-4bcf-81d8-9dc304c6a353', $this->invoice->id()); self::assertSame('2019/01/000000001', $this->invoice->number()); + self::assertSame('invoice.pdf', $this->invoice->path()); self::assertSame($this->order, $this->invoice->order()); self::assertSame($this->billingData, $this->invoice->billingData()); self::assertSame('USD', $this->invoice->currencyCode()); diff --git a/tests/Unit/Factory/InvoiceFactoryTest.php b/tests/Unit/Factory/InvoiceFactoryTest.php index 87ff411c..3fdaa1a7 100644 --- a/tests/Unit/Factory/InvoiceFactoryTest.php +++ b/tests/Unit/Factory/InvoiceFactoryTest.php @@ -27,21 +27,26 @@ use Sylius\InvoicingPlugin\Entity\InvoiceShopBillingDataInterface; use Sylius\InvoicingPlugin\Factory\InvoiceFactory; use Sylius\InvoicingPlugin\Factory\InvoiceFactoryInterface; +use Sylius\InvoicingPlugin\Generator\InvoiceFileNameGenerator; +use Sylius\InvoicingPlugin\Generator\InvoiceFileNameGeneratorInterface; final class InvoiceFactoryTest extends TestCase { private FactoryInterface&MockObject $invoiceShopBillingDataFactory; + private InvoiceFileNameGeneratorInterface&MockObject $invoiceFileNameGenerator; + private InvoiceFactory $invoiceFactory; protected function setUp(): void { parent::setUp(); $this->invoiceShopBillingDataFactory = $this->createMock(FactoryInterface::class); - + $this->invoiceFileNameGenerator = $this->createMock(InvoiceFileNameGeneratorInterface::class); $this->invoiceFactory = new InvoiceFactory( Invoice::class, $this->invoiceShopBillingDataFactory, + $this->invoiceFileNameGenerator, ); } @@ -49,6 +54,7 @@ protected function setUp(): void public function it_implements_invoice_factory_interface(): void { self::assertInstanceOf(InvoiceFactoryInterface::class, $this->invoiceFactory); + } #[Test] @@ -61,6 +67,12 @@ public function it_creates_an_invoice_for_given_data(): void $date = new \DateTimeImmutable('2019-03-06'); + $this->invoiceFileNameGenerator + ->expects(self::once()) + ->method('generateForPdf') + ->with('2019/03/0000001') + ->willReturn('2019_03_0000001.pdf'); + $result = $this->invoiceFactory->createForData( '7903c83a-4c5e-4bcf-81d8-9dc304c6a353', '2019/03/0000001', @@ -94,6 +106,12 @@ public function it_allows_for_nullable_shop_billing_data(): void ->method('createNew') ->willReturn(new InvoiceShopBillingData()); + $this->invoiceFileNameGenerator + ->expects(self::once()) + ->method('generateForPdf') + ->with('2019/03/0000001') + ->willReturn('2019_03_0000001.pdf'); + $result = $this->invoiceFactory->createForData( '7903c83a-4c5e-4bcf-81d8-9dc304c6a353', '2019/03/0000001', diff --git a/tests/Unit/Generator/InvoiceFileNameGeneratorTest.php b/tests/Unit/Generator/InvoiceFileNameGeneratorTest.php index c2697c3a..65be4263 100644 --- a/tests/Unit/Generator/InvoiceFileNameGeneratorTest.php +++ b/tests/Unit/Generator/InvoiceFileNameGeneratorTest.php @@ -1,21 +1,11 @@ generator = new InvoiceFileNameGenerator(); } @@ -38,10 +29,27 @@ public function it_implements_invoice_file_name_generator_interface(): void #[Test] public function it_generates_invoice_file_name_based_on_its_number(): void { - $invoice = $this->createMock(InvoiceInterface::class); - $invoice->method('number')->willReturn('2020/01/02/000333'); + $result = $this->generator->generateForPdf('2020/01/02/000333'); + + self::assertSame('2020_01_02_000333.pdf', $result); + } + + #[Test] + public function it_generates_scoped_file_name_when_scope_is_set(): void + { + $generator = new InvoiceFileNameGenerator('monthly'); + + $result = $generator->generateForPdf('2020/01/02/000333'); + + self::assertSame('monthly/2020_01_02_000333.pdf', $result); + } + + #[Test] + public function it_uses_global_scope_when_scope_is_invalid_or_null(): void + { + $generator = new InvoiceFileNameGenerator('invalid_scope'); - $result = $this->generator->generateForPdf($invoice); + $result = $generator->generateForPdf('2020/01/02/000333'); self::assertSame('2020_01_02_000333.pdf', $result); } diff --git a/tests/Unit/Generator/InvoicePdfFileGeneratorTest.php b/tests/Unit/Generator/InvoicePdfFileGeneratorTest.php index 26e830d4..958318ee 100644 --- a/tests/Unit/Generator/InvoicePdfFileGeneratorTest.php +++ b/tests/Unit/Generator/InvoicePdfFileGeneratorTest.php @@ -31,8 +31,6 @@ final class InvoicePdfFileGeneratorTest extends TestCase private FileLocatorInterface&MockObject $fileLocator; - private InvoiceFileNameGeneratorInterface&MockObject $invoiceFileNameGenerator; - private InvoicePdfFileGenerator $generator; protected function setUp(): void @@ -40,12 +38,10 @@ protected function setUp(): void parent::setUp(); $this->twigToPdfGenerator = $this->createMock(TwigToPdfGeneratorInterface::class); $this->fileLocator = $this->createMock(FileLocatorInterface::class); - $this->invoiceFileNameGenerator = $this->createMock(InvoiceFileNameGeneratorInterface::class); $this->generator = new InvoicePdfFileGenerator( $this->twigToPdfGenerator, $this->fileLocator, - $this->invoiceFileNameGenerator, 'invoiceTemplate.html.twig', '@SyliusInvoicingPlugin/assets/sylius-logo.png', ); @@ -63,11 +59,10 @@ public function it_creates_invoice_pdf_with_generated_content_and_filename_basin $invoice = $this->createMock(InvoiceInterface::class); $channel = $this->createMock(ChannelInterface::class); - $this->invoiceFileNameGenerator + $invoice ->expects(self::once()) - ->method('generateForPdf') - ->with($invoice) - ->willReturn('2015_05_00004444.pdf'); + ->method('path') + ->willReturn('invoice.pdf'); $invoice->method('channel')->willReturn($channel); @@ -85,7 +80,7 @@ public function it_creates_invoice_pdf_with_generated_content_and_filename_basin $result = $this->generator->generate($invoice); - $expected = new InvoicePdf('2015_05_00004444.pdf', 'PDF FILE'); + $expected = new InvoicePdf('invoice.pdf', 'PDF FILE'); self::assertEquals($expected, $result); } diff --git a/tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php b/tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php index f04a331e..21f7c89d 100644 --- a/tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php +++ b/tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php @@ -21,6 +21,7 @@ use Sylius\Component\Resource\Factory\FactoryInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; use Sylius\InvoicingPlugin\Entity\InvoiceSequenceInterface; +use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; use Sylius\InvoicingPlugin\Generator\InvoiceNumberGenerator; use Sylius\InvoicingPlugin\Generator\SequentialInvoiceNumberGenerator; use Symfony\Component\Clock\ClockInterface; @@ -69,7 +70,10 @@ public function it_generates_invoice_number(): void $dateTime = new \DateTimeImmutable('now'); $this->clock->method('now')->willReturn($dateTime); - $this->sequenceRepository->method('findOneBy')->with([])->willReturn($sequence); + $this->sequenceRepository + ->method('findOneBy') + ->with(['year' => null, 'month' => null]) + ->willReturn($sequence); $sequence->method('getVersion')->willReturn(1); $sequence->method('getIndex')->willReturn(0); @@ -96,7 +100,10 @@ public function it_generates_invoice_number_when_sequence_is_null(): void $dateTime = new \DateTimeImmutable('now'); $this->clock->method('now')->willReturn($dateTime); - $this->sequenceRepository->method('findOneBy')->with([])->willReturn(null); + $this->sequenceRepository + ->method('findOneBy') + ->with(['year' => null, 'month' => null]) + ->willReturn(null); $this->sequenceFactory->method('createNew')->willReturn($sequence); @@ -119,6 +126,139 @@ public function it_generates_invoice_number_when_sequence_is_null(): void $result = $this->generator->generate(); - $this->assertSame($dateTime->format('Y/m') . '/000000001', $result); + self::assertSame($dateTime->format('Y/m') . '/000000001', $result); + } + + #[Test] + public function it_generates_invoice_number_with_monthly_scope(): void + { + $sequence = $this->createMock(InvoiceSequenceInterface::class); + + $dateTime = new \DateTimeImmutable('2025-10-15'); + $this->clock->method('now')->willReturn($dateTime); + + $generator = new SequentialInvoiceNumberGenerator( + $this->sequenceRepository, + $this->sequenceFactory, + $this->sequenceManager, + $this->clock, + 1, + 9, + 'monthly' + ); + + $this->sequenceRepository + ->method('findOneBy') + ->with(['year' => 2025, 'month' => 10, 'type' => InvoiceSequenceScopeEnum::MONTHLY]) + ->willReturn($sequence); + + $sequence->method('getVersion')->willReturn(1); + $sequence->method('getIndex')->willReturn(0); + + $this->sequenceManager + ->expects(self::once()) + ->method('lock') + ->with($sequence, LockMode::OPTIMISTIC, 1); + + $sequence + ->expects(self::once()) + ->method('incrementIndex'); + + $result = $generator->generate(); + + self::assertSame('2025/10/000000001', $result); + } + + #[Test] + public function it_generates_invoice_number_with_annually_scope(): void + { + $sequence = $this->createMock(InvoiceSequenceInterface::class); + + $dateTime = new \DateTimeImmutable('2025-11-15'); + $this->clock->method('now')->willReturn($dateTime); + + $generator = new SequentialInvoiceNumberGenerator( + $this->sequenceRepository, + $this->sequenceFactory, + $this->sequenceManager, + $this->clock, + 1, + 9, + 'annually' + ); + + $this->sequenceRepository + ->method('findOneBy') + ->with(['year' => 2025, 'type' => InvoiceSequenceScopeEnum::ANNUALLY]) + ->willReturn($sequence); + + $sequence->method('getVersion')->willReturn(1); + $sequence->method('getIndex')->willReturn(0); + + $this->sequenceManager + ->expects(self::once()) + ->method('lock') + ->with($sequence, LockMode::OPTIMISTIC, 1); + + $sequence + ->expects(self::once()) + ->method('incrementIndex'); + + $result = $generator->generate(); + + self::assertSame('2025/11/000000001', $result); + } + + #[Test] + public function it_generates_invoice_number_when_monthly_sequence_is_null(): void + { + $sequence = $this->createMock(InvoiceSequenceInterface::class); + + $dateTime = new \DateTimeImmutable('2025-10-15'); + $this->clock->method('now')->willReturn($dateTime); + + $generator = new SequentialInvoiceNumberGenerator( + $this->sequenceRepository, + $this->sequenceFactory, + $this->sequenceManager, + $this->clock, + 1, + 9, + 'monthly' + ); + + $scope = InvoiceSequenceScopeEnum::MONTHLY; + + $this->sequenceRepository + ->expects(self::once()) + ->method('findOneBy') + ->with(['year' => 2025, 'month' => 10, 'type' => $scope]) + ->willReturn(null); + + $this->sequenceFactory->expects(self::once())->method('createNew')->willReturn($sequence); + $sequence->expects(self::once())->method('setYear')->with(2025); + $sequence->expects(self::once())->method('setMonth')->with(10); + $sequence->expects(self::once())->method('setType')->with($scope); + + $this->sequenceManager + ->expects(self::once()) + ->method('persist') + ->with($sequence); + + $sequence->method('getVersion')->willReturn(1); + $sequence->method('getIndex')->willReturn(0); + + $this->sequenceManager + ->expects(self::once()) + ->method('lock') + ->with($sequence, LockMode::OPTIMISTIC, 1); + + $sequence + ->expects(self::once()) + ->method('incrementIndex'); + + $result = $generator->generate(); + + self::assertSame('2025/10/000000001', $result); } } diff --git a/tests/Unit/Provider/InvoiceFileProviderTest.php b/tests/Unit/Provider/InvoiceFileProviderTest.php index 541df8a7..5493ee99 100644 --- a/tests/Unit/Provider/InvoiceFileProviderTest.php +++ b/tests/Unit/Provider/InvoiceFileProviderTest.php @@ -29,8 +29,6 @@ final class InvoiceFileProviderTest extends TestCase { - private InvoiceFileNameGeneratorInterface&MockObject $invoiceFileNameGenerator; - private FilesystemInterface&MockObject $filesystem; private InvoicePdfFileGeneratorInterface&MockObject $invoicePdfFileGenerator; @@ -42,13 +40,11 @@ final class InvoiceFileProviderTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->invoiceFileNameGenerator = $this->createMock(InvoiceFileNameGeneratorInterface::class); $this->filesystem = $this->createMock(FilesystemInterface::class); $this->invoicePdfFileGenerator = $this->createMock(InvoicePdfFileGeneratorInterface::class); $this->invoiceFileManager = $this->createMock(InvoiceFileManagerInterface::class); $this->provider = new InvoiceFileProvider( - $this->invoiceFileNameGenerator, $this->filesystem, $this->invoicePdfFileGenerator, $this->invoiceFileManager, @@ -68,10 +64,9 @@ public function it_provides_invoice_file_for_invoice(): void $invoice = $this->createMock(InvoiceInterface::class); $invoiceFile = $this->createMock(File::class); - $this->invoiceFileNameGenerator + $invoice ->expects(self::once()) - ->method('generateForPdf') - ->with($invoice) + ->method('path') ->willReturn('invoice.pdf'); $this->filesystem @@ -98,10 +93,9 @@ public function it_generates_invoice_if_it_does_not_exist_and_provides_it(): voi { $invoice = $this->createMock(InvoiceInterface::class); - $this->invoiceFileNameGenerator + $invoice ->expects(self::once()) - ->method('generateForPdf') - ->with($invoice) + ->method('path') ->willReturn('invoice.pdf'); $this->filesystem