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);
}
}