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/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/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/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/generators.xml b/config/services/generators.xml index cfca0642..c01a26b6 100644 --- a/config/services/generators.xml +++ b/config/services/generators.xml @@ -22,6 +22,7 @@ + %sylius_invoicing.sequence_scope% 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 @@ +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/tests/TestApplication/.env b/tests/TestApplication/.env index 657afa80..8ce5f4f4 100644 --- a/tests/TestApplication/.env +++ b/tests/TestApplication/.env @@ -9,3 +9,6 @@ WKHTMLTOPDF_PATH=/usr/local/bin/wkhtmltopdf ###< knplabs/knp-snappy-bundle ### TEST_SYLIUS_INVOICING_PDF_GENERATION_DISABLED=false +TEST_SYLIUS_INVOICING_PDF_GENERATION_DISABLED=false + +SYLIUS_INVOICING_SEQUENCE_SCOPE='monthly' 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); } }