From 6570657ef3a56fc707614b37bb058aa7f283b245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Fri, 17 Jul 2026 12:16:27 +0200 Subject: [PATCH 1/7] Introduce invoice sequence scoping (global, monthly, annually). --- config/doctrine/InvoiceSequence.orm.xml | 15 ++++++++ src/DependencyInjection/Configuration.php | 23 +++++++++++ .../SyliusInvoicingExtension.php | 5 +++ src/Entity/InvoiceSequence.php | 38 +++++++++++++++++++ src/Entity/InvoiceSequenceInterface.php | 13 +++++++ src/Enum/InvoiceSequenceScopeEnum.php | 21 ++++++++++ .../SequentialInvoiceNumberGenerator.php | 28 ++++++++++++-- src/Migrations/Version20260717080000.php | 37 ++++++++++++++++++ tests/TestApplication/config/config.yaml | 4 ++ 9 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/Enum/InvoiceSequenceScopeEnum.php create mode 100644 src/Migrations/Version20260717080000.php diff --git a/config/doctrine/InvoiceSequence.orm.xml b/config/doctrine/InvoiceSequence.orm.xml index ed04a5e4..9731a944 100644 --- a/config/doctrine/InvoiceSequence.orm.xml +++ b/config/doctrine/InvoiceSequence.orm.xml @@ -11,6 +11,21 @@ + + + + + + + + + + + + + + + diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 9389a97c..d43a9e10 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -28,6 +28,7 @@ use Sylius\InvoicingPlugin\Entity\LineItemInterface; use Sylius\InvoicingPlugin\Entity\TaxItem; use Sylius\InvoicingPlugin\Entity\TaxItemInterface; +use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; use Sylius\InvoicingPlugin\Factory\BillingDataFactory; use Sylius\InvoicingPlugin\Factory\InvoiceShopBillingDataFactory; use Sylius\InvoicingPlugin\Factory\LineItemFactory; @@ -45,10 +46,32 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addResourcesSection($rootNode); $this->addPdfGeneratorSection($rootNode); + $this->addSequenceSection($rootNode); return $treeBuilder; } + private function addSequenceSection(ArrayNodeDefinition $node): void + { + $node + ->children() + ->arrayNode('sequence') + ->addDefaultsIfNotSet() + ->children() + ->enumNode('scope') + ->info('Scope of invoice number sequences: "global", "monthly" or "annually".') + ->values(array_map( + static fn (InvoiceSequenceScopeEnum $scope): string => $scope->value, + InvoiceSequenceScopeEnum::cases(), + )) + ->defaultValue(InvoiceSequenceScopeEnum::GLOBAL->value) + ->end() + ->end() + ->end() + ->end() + ; + } + private function addResourcesSection(ArrayNodeDefinition $node): void { $node diff --git a/src/DependencyInjection/SyliusInvoicingExtension.php b/src/DependencyInjection/SyliusInvoicingExtension.php index 46bd0681..1e3c3823 100644 --- a/src/DependencyInjection/SyliusInvoicingExtension.php +++ b/src/DependencyInjection/SyliusInvoicingExtension.php @@ -15,6 +15,7 @@ use Sylius\Bundle\CoreBundle\DependencyInjection\PrependDoctrineMigrationsTrait; use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension; +use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; use Sylius\InvoicingPlugin\Generator\InvoicingAllowedFilesOptionsProcessor; use Sylius\PdfGenerationBundle\Core\Filesystem\Manager\PdfFileManagerInterface; use Sylius\PdfGenerationBundle\Core\Renderer\TwigToPdfRendererInterface; @@ -41,6 +42,10 @@ public function load(array $configs, ContainerBuilder $container): void $config = $this->processConfiguration($configuration, $configs); $container->setParameter('sylius_invoicing.pdf_generator.allowed_files', $config['pdf_generator']['allowed_files']); + $container->getDefinition('sylius_invoicing.generator.invoice_number') + ->setArgument('$scope', InvoiceSequenceScopeEnum::from($config['sequence']['scope'])) + ; + // TODO: Remove in 3.0 — once the legacy PDF generator is dropped, the service should be defined directly with its non-legacy arguments instead of being rewired here. if (!$config['pdf_generator']['legacy']) { $container->getDefinition('sylius_invoicing.generator.invoice_pdf_file') diff --git a/src/Entity/InvoiceSequence.php b/src/Entity/InvoiceSequence.php index 064eb8e8..6cfc8d9a 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 = InvoiceSequenceScopeEnum::GLOBAL; + + protected int $year = 0; + + protected int $month = 0; + /** @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..90ffe357 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 setYear(int $year): void; + + public function getMonth(): int; + + 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 @@ +sequenceRepository->findOneBy([]); - - if (null != $sequence) { + $now = $this->clock->now(); + + $criteria = [ + 'type' => $this->scope, + 'year' => match ($this->scope) { + InvoiceSequenceScopeEnum::MONTHLY, InvoiceSequenceScopeEnum::ANNUALLY => (int) $now->format('Y'), + InvoiceSequenceScopeEnum::GLOBAL => 0, + }, + 'month' => match ($this->scope) { + InvoiceSequenceScopeEnum::MONTHLY => (int) $now->format('m'), + InvoiceSequenceScopeEnum::ANNUALLY, InvoiceSequenceScopeEnum::GLOBAL => 0, + }, + ]; + + /** @var InvoiceSequenceInterface|null $sequence */ + $sequence = $this->sequenceRepository->findOneBy($criteria); + + if (null !== $sequence) { return $sequence; } /** @var InvoiceSequenceInterface $sequence */ $sequence = $this->sequenceFactory->createNew(); + $sequence->setType($this->scope); + $sequence->setYear($criteria['year']); + $sequence->setMonth($criteria['month']); + $this->sequenceManager->persist($sequence); return $sequence; diff --git a/src/Migrations/Version20260717080000.php b/src/Migrations/Version20260717080000.php new file mode 100644 index 00000000..5ca637ec --- /dev/null +++ b/src/Migrations/Version20260717080000.php @@ -0,0 +1,37 @@ +addSql("ALTER TABLE sylius_invoicing_plugin_sequence ADD year INT DEFAULT 0 NOT NULL, ADD month INT DEFAULT 0 NOT NULL, ADD type VARCHAR(255) DEFAULT 'global' NOT NULL"); + $this->addSql('CREATE UNIQUE INDEX UNIQ_SYLIUS_INVOICING_SEQUENCE_SCOPE ON sylius_invoicing_plugin_sequence (type, year, month)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX UNIQ_SYLIUS_INVOICING_SEQUENCE_SCOPE ON sylius_invoicing_plugin_sequence'); + $this->addSql('ALTER TABLE sylius_invoicing_plugin_sequence DROP year, DROP month, DROP type'); + } +} diff --git a/tests/TestApplication/config/config.yaml b/tests/TestApplication/config/config.yaml index 84b54a36..3e64755f 100644 --- a/tests/TestApplication/config/config.yaml +++ b/tests/TestApplication/config/config.yaml @@ -2,6 +2,10 @@ imports: - { resource: "@SyliusInvoicingPlugin/config/config.yaml" } - { resource: "services_test.php" } +knp_snappy: + pdf: + binary: '%env(WKHTMLTOPDF_PATH)%' + twig: paths: '%kernel.project_dir%/../../../tests/TestApplication/templates': ~ From e38274d368d053accd5897660c3727ea71606f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Fri, 17 Jul 2026 13:38:31 +0200 Subject: [PATCH 2/7] Refactor invoice sequence scoping to use resolvers instead of enums. --- config/doctrine/InvoiceSequence.orm.xml | 2 +- config/services/generators.xml | 1 + config/services/resolvers.xml | 32 +++++++++++++++++ src/DependencyInjection/Configuration.php | 12 +++---- .../SyliusInvoicingExtension.php | 3 +- src/Entity/InvoiceSequence.php | 8 ++--- src/Entity/InvoiceSequenceInterface.php | 11 ++++-- src/Exception/SequenceScopeNotSupported.php | 22 ++++++++++++ .../SequentialInvoiceNumberGenerator.php | 36 +++++++++++-------- .../AnnuallySequenceScopeResolver.php | 29 +++++++++++++++ src/Resolver/GlobalSequenceScopeResolver.php | 29 +++++++++++++++ src/Resolver/MonthlySequenceScopeResolver.php | 29 +++++++++++++++ .../SequenceScopeResolverInterface.php} | 11 +++--- 13 files changed, 186 insertions(+), 39 deletions(-) create mode 100644 config/services/resolvers.xml create mode 100644 src/Exception/SequenceScopeNotSupported.php create mode 100644 src/Resolver/AnnuallySequenceScopeResolver.php create mode 100644 src/Resolver/GlobalSequenceScopeResolver.php create mode 100644 src/Resolver/MonthlySequenceScopeResolver.php rename src/{Enum/InvoiceSequenceScopeEnum.php => Resolver/SequenceScopeResolverInterface.php} (50%) diff --git a/config/doctrine/InvoiceSequence.orm.xml b/config/doctrine/InvoiceSequence.orm.xml index 9731a944..2be564e8 100644 --- a/config/doctrine/InvoiceSequence.orm.xml +++ b/config/doctrine/InvoiceSequence.orm.xml @@ -21,7 +21,7 @@ - + diff --git a/config/services/generators.xml b/config/services/generators.xml index 2ed6f149..772cba7e 100644 --- a/config/services/generators.xml +++ b/config/services/generators.xml @@ -22,6 +22,7 @@ + diff --git a/config/services/resolvers.xml b/config/services/resolvers.xml new file mode 100644 index 00000000..aae610d5 --- /dev/null +++ b/config/services/resolvers.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index d43a9e10..250490e2 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -28,7 +28,6 @@ use Sylius\InvoicingPlugin\Entity\LineItemInterface; use Sylius\InvoicingPlugin\Entity\TaxItem; use Sylius\InvoicingPlugin\Entity\TaxItemInterface; -use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; use Sylius\InvoicingPlugin\Factory\BillingDataFactory; use Sylius\InvoicingPlugin\Factory\InvoiceShopBillingDataFactory; use Sylius\InvoicingPlugin\Factory\LineItemFactory; @@ -58,13 +57,10 @@ private function addSequenceSection(ArrayNodeDefinition $node): void ->arrayNode('sequence') ->addDefaultsIfNotSet() ->children() - ->enumNode('scope') - ->info('Scope of invoice number sequences: "global", "monthly" or "annually".') - ->values(array_map( - static fn (InvoiceSequenceScopeEnum $scope): string => $scope->value, - InvoiceSequenceScopeEnum::cases(), - )) - ->defaultValue(InvoiceSequenceScopeEnum::GLOBAL->value) + ->scalarNode('scope') + ->info('Scope of invoice number sequences: "global", "monthly" or "annually". Custom scopes can be added by registering a service tagged with "sylius_invoicing.sequence_scope_resolver".') + ->cannotBeEmpty() + ->defaultValue(InvoiceSequenceInterface::SCOPE_GLOBAL) ->end() ->end() ->end() diff --git a/src/DependencyInjection/SyliusInvoicingExtension.php b/src/DependencyInjection/SyliusInvoicingExtension.php index 1e3c3823..99547ffc 100644 --- a/src/DependencyInjection/SyliusInvoicingExtension.php +++ b/src/DependencyInjection/SyliusInvoicingExtension.php @@ -15,7 +15,6 @@ use Sylius\Bundle\CoreBundle\DependencyInjection\PrependDoctrineMigrationsTrait; use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension; -use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; use Sylius\InvoicingPlugin\Generator\InvoicingAllowedFilesOptionsProcessor; use Sylius\PdfGenerationBundle\Core\Filesystem\Manager\PdfFileManagerInterface; use Sylius\PdfGenerationBundle\Core\Renderer\TwigToPdfRendererInterface; @@ -43,7 +42,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('sylius_invoicing.pdf_generator.allowed_files', $config['pdf_generator']['allowed_files']); $container->getDefinition('sylius_invoicing.generator.invoice_number') - ->setArgument('$scope', InvoiceSequenceScopeEnum::from($config['sequence']['scope'])) + ->setArgument('$scope', $config['sequence']['scope']) ; // TODO: Remove in 3.0 — once the legacy PDF generator is dropped, the service should be defined directly with its non-legacy arguments instead of being rewired here. diff --git a/src/Entity/InvoiceSequence.php b/src/Entity/InvoiceSequence.php index 6cfc8d9a..010c8c10 100644 --- a/src/Entity/InvoiceSequence.php +++ b/src/Entity/InvoiceSequence.php @@ -13,8 +13,6 @@ namespace Sylius\InvoicingPlugin\Entity; -use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; - /** @final */ class InvoiceSequence implements InvoiceSequenceInterface { @@ -25,7 +23,7 @@ class InvoiceSequence implements InvoiceSequenceInterface protected ?int $version = 1; - protected InvoiceSequenceScopeEnum $type = InvoiceSequenceScopeEnum::GLOBAL; + protected string $type = InvoiceSequenceInterface::SCOPE_GLOBAL; protected int $year = 0; @@ -57,12 +55,12 @@ public function setVersion(?int $version): void $this->version = $version; } - public function getType(): InvoiceSequenceScopeEnum + public function getType(): string { return $this->type; } - public function setType(InvoiceSequenceScopeEnum $type): void + public function setType(string $type): void { $this->type = $type; } diff --git a/src/Entity/InvoiceSequenceInterface.php b/src/Entity/InvoiceSequenceInterface.php index 90ffe357..8e5afb8a 100644 --- a/src/Entity/InvoiceSequenceInterface.php +++ b/src/Entity/InvoiceSequenceInterface.php @@ -15,17 +15,22 @@ use Sylius\Component\Resource\Model\ResourceInterface; use Sylius\Component\Resource\Model\VersionedInterface; -use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum; interface InvoiceSequenceInterface extends ResourceInterface, VersionedInterface { + public const SCOPE_GLOBAL = 'global'; + + public const SCOPE_MONTHLY = 'monthly'; + + public const SCOPE_ANNUALLY = 'annually'; + public function getIndex(): int; public function incrementIndex(): void; - public function getType(): InvoiceSequenceScopeEnum; + public function getType(): string; - public function setType(InvoiceSequenceScopeEnum $type): void; + public function setType(string $type): void; public function getYear(): int; diff --git a/src/Exception/SequenceScopeNotSupported.php b/src/Exception/SequenceScopeNotSupported.php new file mode 100644 index 00000000..1783b7ed --- /dev/null +++ b/src/Exception/SequenceScopeNotSupported.php @@ -0,0 +1,22 @@ + $scopeResolvers */ public function __construct( private readonly RepositoryInterface $sequenceRepository, private readonly FactoryInterface $sequenceFactory, private readonly EntityManagerInterface $sequenceManager, private readonly ClockInterface $clock, + private readonly iterable $scopeResolvers, private readonly int $startNumber = 1, private readonly int $numberLength = 9, - private readonly InvoiceSequenceScopeEnum $scope = InvoiceSequenceScopeEnum::GLOBAL, + private readonly string $scope = InvoiceSequenceInterface::SCOPE_GLOBAL, ) { } @@ -58,19 +61,10 @@ private function generateNumber(int $index): string private function getSequence(): InvoiceSequenceInterface { - $now = $this->clock->now(); - - $criteria = [ - 'type' => $this->scope, - 'year' => match ($this->scope) { - InvoiceSequenceScopeEnum::MONTHLY, InvoiceSequenceScopeEnum::ANNUALLY => (int) $now->format('Y'), - InvoiceSequenceScopeEnum::GLOBAL => 0, - }, - 'month' => match ($this->scope) { - InvoiceSequenceScopeEnum::MONTHLY => (int) $now->format('m'), - InvoiceSequenceScopeEnum::ANNUALLY, InvoiceSequenceScopeEnum::GLOBAL => 0, - }, - ]; + $criteria = array_merge( + ['type' => $this->scope], + $this->resolveScopeCriteria($this->clock->now()), + ); /** @var InvoiceSequenceInterface|null $sequence */ $sequence = $this->sequenceRepository->findOneBy($criteria); @@ -89,4 +83,16 @@ private function getSequence(): InvoiceSequenceInterface return $sequence; } + + /** @return array{year: int, month: int} */ + private function resolveScopeCriteria(\DateTimeImmutable $now): array + { + foreach ($this->scopeResolvers as $scopeResolver) { + if ($scopeResolver->supports($this->scope)) { + return $scopeResolver->resolve($now); + } + } + + throw SequenceScopeNotSupported::withScope($this->scope); + } } diff --git a/src/Resolver/AnnuallySequenceScopeResolver.php b/src/Resolver/AnnuallySequenceScopeResolver.php new file mode 100644 index 00000000..2b8df1ae --- /dev/null +++ b/src/Resolver/AnnuallySequenceScopeResolver.php @@ -0,0 +1,29 @@ + (int) $now->format('Y'), 'month' => 0]; + } +} diff --git a/src/Resolver/GlobalSequenceScopeResolver.php b/src/Resolver/GlobalSequenceScopeResolver.php new file mode 100644 index 00000000..00e63bf5 --- /dev/null +++ b/src/Resolver/GlobalSequenceScopeResolver.php @@ -0,0 +1,29 @@ + 0, 'month' => 0]; + } +} diff --git a/src/Resolver/MonthlySequenceScopeResolver.php b/src/Resolver/MonthlySequenceScopeResolver.php new file mode 100644 index 00000000..126dadcc --- /dev/null +++ b/src/Resolver/MonthlySequenceScopeResolver.php @@ -0,0 +1,29 @@ + (int) $now->format('Y'), 'month' => (int) $now->format('m')]; + } +} diff --git a/src/Enum/InvoiceSequenceScopeEnum.php b/src/Resolver/SequenceScopeResolverInterface.php similarity index 50% rename from src/Enum/InvoiceSequenceScopeEnum.php rename to src/Resolver/SequenceScopeResolverInterface.php index 110876b9..ac1c46b7 100644 --- a/src/Enum/InvoiceSequenceScopeEnum.php +++ b/src/Resolver/SequenceScopeResolverInterface.php @@ -11,11 +11,12 @@ declare(strict_types=1); -namespace Sylius\InvoicingPlugin\Enum; +namespace Sylius\InvoicingPlugin\Resolver; -enum InvoiceSequenceScopeEnum: string +interface SequenceScopeResolverInterface { - case GLOBAL = 'global'; - case MONTHLY = 'monthly'; - case ANNUALLY = 'annually'; + public function supports(string $scope): bool; + + /** @return array{year: int, month: int} */ + public function resolve(\DateTimeImmutable $now): array; } From 29a1886e8febf0036fd86d52f694b446b69a5d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Mon, 20 Jul 2026 09:42:23 +0200 Subject: [PATCH 3/7] Implement invoice sequence scoping with unique constraints and prefix method --- UPGRADE-2.3.md | 54 +++++++++++++++++++ config/doctrine/Invoice.orm.xml | 4 ++ config/doctrine/InvoiceSequence.orm.xml | 4 ++ config/services/generators.xml | 2 +- src/Creator/InvoiceCreator.php | 15 +----- .../SequentialInvoiceNumberGenerator.php | 47 +++++++++++----- ...17080000.php => Version20260717121814.php} | 17 ++---- .../AnnuallySequenceScopeResolver.php | 5 ++ src/Resolver/GlobalSequenceScopeResolver.php | 5 ++ src/Resolver/MonthlySequenceScopeResolver.php | 5 ++ .../SequenceScopeResolverInterface.php | 2 + .../TestApplication/config/services_test.php | 13 ++++- 12 files changed, 131 insertions(+), 42 deletions(-) create mode 100644 UPGRADE-2.3.md rename src/Migrations/{Version20260717080000.php => Version20260717121814.php} (58%) diff --git a/UPGRADE-2.3.md b/UPGRADE-2.3.md new file mode 100644 index 00000000..30895ced --- /dev/null +++ b/UPGRADE-2.3.md @@ -0,0 +1,54 @@ +# UPGRADE FROM 2.2 TO 2.3 + +1. Invoice number sequences can now be scoped. The scope is configurable and defaults to `global`, + which keeps the exact numbering format and counter continuity of previous versions: + + ```yaml + sylius_invoicing: + sequence: + scope: global # one of "global", "monthly", "annually" + ``` + + Custom scopes can be added by registering a service implementing + `Sylius\InvoicingPlugin\Resolver\SequenceScopeResolverInterface`, tagged with + `sylius_invoicing.sequence_scope_resolver`, and setting its name as the `scope` option. + Keep in mind that the number prefix returned by `prefix()` must make invoice numbers unique + across all periods of the scope, as invoice files are stored under names derived from invoice numbers. + +1. Run doctrine migrations when upgrading — the `sylius_invoicing_plugin_sequence` table gains + `type`, `year` and `month` columns, and unique indexes are created on the sequence scope and on + the invoice number. In the unlikely case your database contains duplicated invoice numbers, + the migration will fail and the duplicates have to be resolved manually first. You can check + for duplicates upfront with: + + ```sql + SELECT number, COUNT(*) FROM sylius_invoicing_plugin_invoice GROUP BY number HAVING COUNT(*) > 1; + ``` + +1. The following interfaces and classes have changed as part of the sequence scoping feature: + + - `Sylius\InvoicingPlugin\Entity\InvoiceSequenceInterface` and `Sylius\InvoicingPlugin\Entity\InvoiceSequence` + gained the `getType(): string`, `setType(string $type): void`, `getYear(): int`, `setYear(int $year): void`, + `getMonth(): int` and `setMonth(int $month): void` methods, together with the `SCOPE_GLOBAL`, `SCOPE_MONTHLY` + and `SCOPE_ANNUALLY` constants. Custom implementations must be updated accordingly. + + - `Sylius\InvoicingPlugin\Generator\SequentialInvoiceNumberGenerator` — not passing a value + for the `$scopeResolvers` argument is deprecated and the argument will be required in 3.0; + until then, omitting it falls back to the built-in `global`, `monthly` and `annually` resolvers: + + ```diff + public function __construct( + private readonly RepositoryInterface $sequenceRepository, + private readonly FactoryInterface $sequenceFactory, + private readonly EntityManagerInterface $sequenceManager, + private readonly ClockInterface $clock, + private readonly int $startNumber = 1, + private readonly int $numberLength = 9, + + ?iterable $scopeResolvers = null, + + private readonly string $scope = InvoiceSequenceInterface::SCOPE_GLOBAL, + ) + ``` + + - `Sylius\InvoicingPlugin\Creator\InvoiceCreator` now persists the invoice before writing its PDF file, + so that a duplicated invoice number fails on the database unique constraint without overwriting + a file belonging to another invoice. Database errors are no longer swallowed and propagate to the caller. diff --git a/config/doctrine/Invoice.orm.xml b/config/doctrine/Invoice.orm.xml index c754a98f..9c4c87bb 100644 --- a/config/doctrine/Invoice.orm.xml +++ b/config/doctrine/Invoice.orm.xml @@ -2,6 +2,10 @@ + + + + diff --git a/config/doctrine/InvoiceSequence.orm.xml b/config/doctrine/InvoiceSequence.orm.xml index 2be564e8..12594094 100644 --- a/config/doctrine/InvoiceSequence.orm.xml +++ b/config/doctrine/InvoiceSequence.orm.xml @@ -6,6 +6,10 @@ http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> + + + + diff --git a/config/services/generators.xml b/config/services/generators.xml index 772cba7e..10fe0c9f 100644 --- a/config/services/generators.xml +++ b/config/services/generators.xml @@ -22,7 +22,7 @@ - + diff --git a/src/Creator/InvoiceCreator.php b/src/Creator/InvoiceCreator.php index 4a8806d7..db0dbb26 100644 --- a/src/Creator/InvoiceCreator.php +++ b/src/Creator/InvoiceCreator.php @@ -13,7 +13,6 @@ 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; @@ -61,9 +60,9 @@ public function __invoke(string $orderNumber, \DateTimeInterface $dateTime): voi $invoice = $this->invoiceGenerator->generateForOrder($order, $dateTime); - if (!$this->hasEnabledPdfFileGenerator) { - $this->invoiceRepository->add($invoice); + $this->invoiceRepository->add($invoice); + if (!$this->hasEnabledPdfFileGenerator) { return; } @@ -75,15 +74,5 @@ public function __invoke(string $orderNumber, \DateTimeInterface $dateTime): voi } else { $this->invoiceFileManager->save($invoicePdf); } - - try { - $this->invoiceRepository->add($invoice); - } catch (ORMException) { - if ($this->invoiceFileManager instanceof PdfFileManagerInterface) { - $this->invoiceFileManager->remove($invoicePdf->filename(), 'sylius_invoicing'); - } else { - $this->invoiceFileManager->remove($invoicePdf); - } - } } } diff --git a/src/Generator/SequentialInvoiceNumberGenerator.php b/src/Generator/SequentialInvoiceNumberGenerator.php index 506e416c..72475a18 100644 --- a/src/Generator/SequentialInvoiceNumberGenerator.php +++ b/src/Generator/SequentialInvoiceNumberGenerator.php @@ -19,37 +19,59 @@ use Sylius\Component\Resource\Repository\RepositoryInterface; use Sylius\InvoicingPlugin\Entity\InvoiceSequenceInterface; use Sylius\InvoicingPlugin\Exception\SequenceScopeNotSupported; +use Sylius\InvoicingPlugin\Resolver\AnnuallySequenceScopeResolver; +use Sylius\InvoicingPlugin\Resolver\GlobalSequenceScopeResolver; +use Sylius\InvoicingPlugin\Resolver\MonthlySequenceScopeResolver; use Sylius\InvoicingPlugin\Resolver\SequenceScopeResolverInterface; use Symfony\Component\Clock\ClockInterface; final class SequentialInvoiceNumberGenerator implements InvoiceNumberGenerator { - /** @param iterable $scopeResolvers */ + /** @var iterable */ + private readonly iterable $scopeResolvers; + + /** @param iterable|null $scopeResolvers */ public function __construct( private readonly RepositoryInterface $sequenceRepository, private readonly FactoryInterface $sequenceFactory, private readonly EntityManagerInterface $sequenceManager, private readonly ClockInterface $clock, - private readonly iterable $scopeResolvers, private readonly int $startNumber = 1, private readonly int $numberLength = 9, + ?iterable $scopeResolvers = null, private readonly string $scope = InvoiceSequenceInterface::SCOPE_GLOBAL, ) { + if (null === $scopeResolvers) { + trigger_deprecation( + 'sylius/invoicing-plugin', + '2.3', + 'Not passing a value for the "$scopeResolvers" argument to "%s" is deprecated and the argument will be required in 3.0.', + self::class, + ); + + $scopeResolvers = [ + new GlobalSequenceScopeResolver(), + new MonthlySequenceScopeResolver(), + new AnnuallySequenceScopeResolver(), + ]; + } + + $this->scopeResolvers = $scopeResolvers; } public function generate(): string { - $invoiceIdentifierPrefix = $this->clock->now()->format('Y/m') . '/'; + $now = $this->clock->now(); + $scopeResolver = $this->getScopeResolver(); - /** @var InvoiceSequenceInterface $sequence */ - $sequence = $this->getSequence(); + $sequence = $this->getSequence($scopeResolver->resolve($now)); $this->sequenceManager->lock($sequence, LockMode::OPTIMISTIC, $sequence->getVersion()); $number = $this->generateNumber($sequence->getIndex()); $sequence->incrementIndex(); - return $invoiceIdentifierPrefix . $number; + return $scopeResolver->prefix($now) . $number; } private function generateNumber(int $index): string @@ -59,12 +81,10 @@ private function generateNumber(int $index): string return str_pad((string) $number, $this->numberLength, '0', \STR_PAD_LEFT); } - private function getSequence(): InvoiceSequenceInterface + /** @param array{year: int, month: int} $scopeCriteria */ + private function getSequence(array $scopeCriteria): InvoiceSequenceInterface { - $criteria = array_merge( - ['type' => $this->scope], - $this->resolveScopeCriteria($this->clock->now()), - ); + $criteria = array_merge(['type' => $this->scope], $scopeCriteria); /** @var InvoiceSequenceInterface|null $sequence */ $sequence = $this->sequenceRepository->findOneBy($criteria); @@ -84,12 +104,11 @@ private function getSequence(): InvoiceSequenceInterface return $sequence; } - /** @return array{year: int, month: int} */ - private function resolveScopeCriteria(\DateTimeImmutable $now): array + private function getScopeResolver(): SequenceScopeResolverInterface { foreach ($this->scopeResolvers as $scopeResolver) { if ($scopeResolver->supports($this->scope)) { - return $scopeResolver->resolve($now); + return $scopeResolver; } } diff --git a/src/Migrations/Version20260717080000.php b/src/Migrations/Version20260717121814.php similarity index 58% rename from src/Migrations/Version20260717080000.php rename to src/Migrations/Version20260717121814.php index 5ca637ec..5bcd177e 100644 --- a/src/Migrations/Version20260717080000.php +++ b/src/Migrations/Version20260717121814.php @@ -1,14 +1,5 @@ addSql("ALTER TABLE sylius_invoicing_plugin_sequence ADD year INT DEFAULT 0 NOT NULL, ADD month INT DEFAULT 0 NOT NULL, ADD type VARCHAR(255) DEFAULT 'global' NOT NULL"); + $this->addSql('CREATE UNIQUE INDEX UNIQ_SYLIUS_INVOICING_INVOICE_NUMBER ON sylius_invoicing_plugin_invoice (number)'); + $this->addSql('ALTER TABLE sylius_invoicing_plugin_sequence ADD year INT DEFAULT 0 NOT NULL, ADD month INT DEFAULT 0 NOT NULL, ADD type VARCHAR(255) DEFAULT \'global\' NOT NULL'); $this->addSql('CREATE UNIQUE INDEX UNIQ_SYLIUS_INVOICING_SEQUENCE_SCOPE ON sylius_invoicing_plugin_sequence (type, year, month)'); } public function down(Schema $schema): void { + $this->addSql('DROP INDEX UNIQ_SYLIUS_INVOICING_INVOICE_NUMBER ON sylius_invoicing_plugin_invoice'); $this->addSql('DROP INDEX UNIQ_SYLIUS_INVOICING_SEQUENCE_SCOPE ON sylius_invoicing_plugin_sequence'); $this->addSql('ALTER TABLE sylius_invoicing_plugin_sequence DROP year, DROP month, DROP type'); } diff --git a/src/Resolver/AnnuallySequenceScopeResolver.php b/src/Resolver/AnnuallySequenceScopeResolver.php index 2b8df1ae..7c5ba6b8 100644 --- a/src/Resolver/AnnuallySequenceScopeResolver.php +++ b/src/Resolver/AnnuallySequenceScopeResolver.php @@ -26,4 +26,9 @@ public function resolve(\DateTimeImmutable $now): array { return ['year' => (int) $now->format('Y'), 'month' => 0]; } + + public function prefix(\DateTimeImmutable $now): string + { + return $now->format('Y/m') . '/'; + } } diff --git a/src/Resolver/GlobalSequenceScopeResolver.php b/src/Resolver/GlobalSequenceScopeResolver.php index 00e63bf5..842fad2a 100644 --- a/src/Resolver/GlobalSequenceScopeResolver.php +++ b/src/Resolver/GlobalSequenceScopeResolver.php @@ -26,4 +26,9 @@ public function resolve(\DateTimeImmutable $now): array { return ['year' => 0, 'month' => 0]; } + + public function prefix(\DateTimeImmutable $now): string + { + return $now->format('Y/m') . '/'; + } } diff --git a/src/Resolver/MonthlySequenceScopeResolver.php b/src/Resolver/MonthlySequenceScopeResolver.php index 126dadcc..026707e2 100644 --- a/src/Resolver/MonthlySequenceScopeResolver.php +++ b/src/Resolver/MonthlySequenceScopeResolver.php @@ -26,4 +26,9 @@ public function resolve(\DateTimeImmutable $now): array { return ['year' => (int) $now->format('Y'), 'month' => (int) $now->format('m')]; } + + public function prefix(\DateTimeImmutable $now): string + { + return $now->format('Y/m') . '/'; + } } diff --git a/src/Resolver/SequenceScopeResolverInterface.php b/src/Resolver/SequenceScopeResolverInterface.php index ac1c46b7..ceb5b33c 100644 --- a/src/Resolver/SequenceScopeResolverInterface.php +++ b/src/Resolver/SequenceScopeResolverInterface.php @@ -19,4 +19,6 @@ public function supports(string $scope): bool; /** @return array{year: int, month: int} */ public function resolve(\DateTimeImmutable $now): array; + + public function prefix(\DateTimeImmutable $now): string; } diff --git a/tests/TestApplication/config/services_test.php b/tests/TestApplication/config/services_test.php index 83a9ccbb..988c3034 100644 --- a/tests/TestApplication/config/services_test.php +++ b/tests/TestApplication/config/services_test.php @@ -1,5 +1,14 @@ import('@SyliusInvoicingPlugin/tests/Behat/Resources/services.xml'); } - if (filter_var($_ENV['TEST_SYLIUS_INVOICING_PDF_GENERATION_DISABLED'], FILTER_VALIDATE_BOOLEAN)) { + if (filter_var($_ENV['TEST_SYLIUS_INVOICING_PDF_GENERATION_DISABLED'], \FILTER_VALIDATE_BOOLEAN)) { $container->import('sylius_invoicing_pdf_generation_disabled.yaml'); } - if (!filter_var($_ENV['TEST_SYLIUS_INVOICING_PDF_LEGACY'] ?? 'true', FILTER_VALIDATE_BOOLEAN)) { + if (!filter_var($_ENV['TEST_SYLIUS_INVOICING_PDF_LEGACY'] ?? 'true', \FILTER_VALIDATE_BOOLEAN)) { $container->extension('sylius_invoicing', [ 'pdf_generator' => ['legacy' => false], ]); From c2e7f2e2a19c40a21d1fe8de95dfddf8da12187b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Mon, 20 Jul 2026 09:43:06 +0200 Subject: [PATCH 4/7] Add unit tests for annually and monthly sequence scope resolvers --- .../SyliusInvoicingConfigurationTest.php | 8 +- .../SyliusInvoicingExtensionTest.php | 79 +++++-- tests/Unit/Creator/InvoiceCreatorTest.php | 53 ++--- .../SequentialInvoiceNumberGeneratorTest.php | 195 +++++++++++++++++- .../AnnuallySequenceScopeResolverTest.php | 51 +++++ .../GlobalSequenceScopeResolverTest.php | 51 +++++ .../MonthlySequenceScopeResolverTest.php | 51 +++++ 7 files changed, 420 insertions(+), 68 deletions(-) create mode 100644 tests/Unit/Resolver/AnnuallySequenceScopeResolverTest.php create mode 100644 tests/Unit/Resolver/GlobalSequenceScopeResolverTest.php create mode 100644 tests/Unit/Resolver/MonthlySequenceScopeResolverTest.php diff --git a/tests/DependencyInjection/SyliusInvoicingConfigurationTest.php b/tests/DependencyInjection/SyliusInvoicingConfigurationTest.php index d4938407..2276f3ec 100644 --- a/tests/DependencyInjection/SyliusInvoicingConfigurationTest.php +++ b/tests/DependencyInjection/SyliusInvoicingConfigurationTest.php @@ -27,7 +27,7 @@ public function it_does_not_define_any_allowed_files_by_default(): void $this->assertProcessedConfigurationEquals( [[]], ['pdf_generator' => ['allowed_files' => []]], - 'pdf_generator.allowed_files' + 'pdf_generator.allowed_files', ); } @@ -37,7 +37,7 @@ public function it_allows_to_define_allowed_files(): void $this->assertProcessedConfigurationEquals( [['pdf_generator' => ['allowed_files' => ['swans.png', 'product.png']]]], ['pdf_generator' => ['allowed_files' => ['swans.png', 'product.png']]], - 'pdf_generator.allowed_files' + 'pdf_generator.allowed_files', ); } @@ -47,7 +47,7 @@ public function it_has_enabled_pdf_generator_by_default(): void $this->assertProcessedConfigurationEquals( [], ['pdf_generator' => ['enabled' => true]], - 'pdf_generator.enabled' + 'pdf_generator.enabled', ); } @@ -57,7 +57,7 @@ public function it_allows_to_disable_pdf_generator(): void $this->assertProcessedConfigurationEquals( [['pdf_generator' => ['enabled' => false]]], ['pdf_generator' => ['enabled' => false]], - 'pdf_generator.enabled' + 'pdf_generator.enabled', ); } diff --git a/tests/DependencyInjection/SyliusInvoicingExtensionTest.php b/tests/DependencyInjection/SyliusInvoicingExtensionTest.php index 1eee5b3d..09e30ce7 100644 --- a/tests/DependencyInjection/SyliusInvoicingExtensionTest.php +++ b/tests/DependencyInjection/SyliusInvoicingExtensionTest.php @@ -20,6 +20,7 @@ use Sylius\InvoicingPlugin\Entity\BillingData; use Sylius\InvoicingPlugin\Entity\Invoice; use Sylius\InvoicingPlugin\Entity\InvoiceSequence; +use Sylius\InvoicingPlugin\Entity\InvoiceSequenceInterface; use Sylius\InvoicingPlugin\Entity\InvoiceShopBillingData; use Sylius\InvoicingPlugin\Entity\LineItem; use Sylius\InvoicingPlugin\Entity\TaxItem; @@ -28,6 +29,7 @@ use Sylius\PdfGenerationBundle\Core\Filesystem\Manager\PdfFileManagerInterface; use Sylius\PdfGenerationBundle\Core\Renderer\TwigToPdfRendererInterface; use SyliusLabs\DoctrineMigrationsExtraBundle\DependencyInjection\SyliusLabsDoctrineMigrationsExtraExtension; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; class SyliusInvoicingExtensionTest extends AbstractExtensionTestCase @@ -42,11 +44,11 @@ public function it_autoconfigures_prepending_doctrine_migration_with_proper_migr $doctrineMigrationsExtensionConfig = $this->container->getExtensionConfig('doctrine_migrations'); self::assertTrue(isset( - $doctrineMigrationsExtensionConfig[0]['migrations_paths']['Sylius\InvoicingPlugin\Migrations'] + $doctrineMigrationsExtensionConfig[0]['migrations_paths']['Sylius\InvoicingPlugin\Migrations'], )); self::assertSame( '@SyliusInvoicingPlugin/src/Migrations', - $doctrineMigrationsExtensionConfig[0]['migrations_paths']['Sylius\InvoicingPlugin\Migrations'] + $doctrineMigrationsExtensionConfig[0]['migrations_paths']['Sylius\InvoicingPlugin\Migrations'], ); $syliusLabsDoctrineMigrationsExtraExtensionConfig = $this @@ -55,11 +57,11 @@ public function it_autoconfigures_prepending_doctrine_migration_with_proper_migr ; self::assertTrue(isset( - $syliusLabsDoctrineMigrationsExtraExtensionConfig[0]['migrations']['Sylius\InvoicingPlugin\Migrations'] + $syliusLabsDoctrineMigrationsExtraExtensionConfig[0]['migrations']['Sylius\InvoicingPlugin\Migrations'], )); self::assertSame( 'Sylius\Bundle\CoreBundle\Migrations', - $syliusLabsDoctrineMigrationsExtraExtensionConfig[0]['migrations']['Sylius\InvoicingPlugin\Migrations'][0] + $syliusLabsDoctrineMigrationsExtraExtensionConfig[0]['migrations']['Sylius\InvoicingPlugin\Migrations'][0], ); } @@ -91,7 +93,7 @@ public function it_loads_allowed_files_for_pdf_generator_configuration(): void $this->assertContainerBuilderHasParameter( 'sylius_invoicing.pdf_generator.allowed_files', - ['swans.png', 'product.png'] + ['swans.png', 'product.png'], ); } @@ -100,7 +102,7 @@ public function it_prepends_configuration_with_enabled_pdf_generator(): void { $this->container->prependExtensionConfig( 'sylius_invoicing', - ['pdf_generator' => ['enabled' => false]] + ['pdf_generator' => ['enabled' => false]], ); $this->prepend(); @@ -257,6 +259,47 @@ public function it_does_not_replace_creator_or_provider_arguments_when_legacy_is ); } + /** @test */ + public function it_injects_global_sequence_scope_by_default(): void + { + $this->load(); + + self::assertSame( + InvoiceSequenceInterface::SCOPE_GLOBAL, + $this->container->getDefinition('sylius_invoicing.generator.invoice_number')->getArgument('$scope'), + ); + } + + /** @test */ + public function it_injects_configured_sequence_scope(): void + { + $this->load(['sequence' => ['scope' => 'monthly']]); + + self::assertSame( + InvoiceSequenceInterface::SCOPE_MONTHLY, + $this->container->getDefinition('sylius_invoicing.generator.invoice_number')->getArgument('$scope'), + ); + } + + /** @test */ + public function it_injects_custom_sequence_scope(): void + { + $this->load(['sequence' => ['scope' => 'weekly']]); + + self::assertSame( + 'weekly', + $this->container->getDefinition('sylius_invoicing.generator.invoice_number')->getArgument('$scope'), + ); + } + + /** @test */ + public function it_throws_an_exception_when_sequence_scope_is_empty(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->load(['sequence' => ['scope' => '']]); + } + /** @test */ public function it_prepends_sylius_pdf_storage_configuration_when_legacy_is_disabled(): void { @@ -330,12 +373,12 @@ public function it_prepends_configuration_with_invoice_resource_services(): void $this->assertContainerBuilderHasParameter( 'sylius_invoicing.model.invoice.class', - Invoice::class + Invoice::class, ); $this->assertContainerBuilderHasService( 'sylius_invoicing.controller.invoice', - ResourceController::class + ResourceController::class, ); } @@ -346,12 +389,12 @@ public function it_prepends_configuration_with_billing_data_resource_services(): $this->assertContainerBuilderHasParameter( 'sylius_invoicing.model.billing_data.class', - BillingData::class + BillingData::class, ); $this->assertContainerBuilderHasService( 'sylius_invoicing.controller.billing_data', - ResourceController::class + ResourceController::class, ); } @@ -362,12 +405,12 @@ public function it_prepends_configuration_with_shop_billing_data_resource_servic $this->assertContainerBuilderHasParameter( 'sylius_invoicing.model.shop_billing_data.class', - InvoiceShopBillingData::class + InvoiceShopBillingData::class, ); $this->assertContainerBuilderHasService( 'sylius_invoicing.controller.shop_billing_data', - ResourceController::class + ResourceController::class, ); } @@ -378,12 +421,12 @@ public function it_prepends_configuration_with_line_item_resource_services(): vo $this->assertContainerBuilderHasParameter( 'sylius_invoicing.model.line_item.class', - LineItem::class + LineItem::class, ); $this->assertContainerBuilderHasService( 'sylius_invoicing.controller.line_item', - ResourceController::class + ResourceController::class, ); } @@ -394,12 +437,12 @@ public function it_prepends_configuration_with_tax_item_resource_services(): voi $this->assertContainerBuilderHasParameter( 'sylius_invoicing.model.tax_item.class', - TaxItem::class + TaxItem::class, ); $this->assertContainerBuilderHasService( 'sylius_invoicing.controller.tax_item', - ResourceController::class + ResourceController::class, ); } @@ -410,12 +453,12 @@ public function it_prepends_configuration_with_invoice_sequence_resource_service $this->assertContainerBuilderHasParameter( 'sylius_invoicing.model.invoice_sequence.class', - InvoiceSequence::class + InvoiceSequence::class, ); $this->assertContainerBuilderHasService( 'sylius_invoicing.controller.invoice_sequence', - ResourceController::class + ResourceController::class, ); } diff --git a/tests/Unit/Creator/InvoiceCreatorTest.php b/tests/Unit/Creator/InvoiceCreatorTest.php index 3ba8872e..96900cc2 100644 --- a/tests/Unit/Creator/InvoiceCreatorTest.php +++ b/tests/Unit/Creator/InvoiceCreatorTest.php @@ -165,11 +165,10 @@ public function it_creates_invoice_without_generating_pdf_file(): void } #[Test] - public function it_removes_saved_invoice_file_if_database_update_fails(): void + public function it_does_not_write_invoice_file_when_persisting_an_invoice_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 @@ -190,27 +189,21 @@ public function it_removes_saved_invoice_file_if_database_update_fails(): 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') ->with($invoice) ->willThrowException(new EntityNotFoundException()); + $this->invoicePdfFileGenerator + ->expects($this->never()) + ->method('generate'); + $this->invoiceFileManager - ->expects(self::once()) - ->method('remove') - ->with($invoicePdf); + ->expects($this->never()) + ->method('save'); + + $this->expectException(EntityNotFoundException::class); ($this->creator)('0000001', $invoiceDateTime); } @@ -274,7 +267,7 @@ public function it_creates_invoice_for_order_using_pdf_bundle_file_manager(): vo } #[Test] - public function it_removes_saved_invoice_file_using_pdf_bundle_if_database_update_fails(): void + public function it_does_not_write_invoice_file_using_pdf_bundle_when_persisting_an_invoice_fails(): void { $pdfFileManager = $this->createMock(PdfFileManagerInterface::class); @@ -288,7 +281,6 @@ public function it_removes_saved_invoice_file_using_pdf_bundle_if_database_updat $order = $this->createMock(OrderInterface::class); $invoice = $this->createMock(InvoiceInterface::class); - $invoicePdf = new InvoicePdf('invoice.pdf', 'CONTENT'); $invoiceDateTime = new \DateTimeImmutable('2019-02-25'); $this->orderRepository @@ -309,30 +301,21 @@ public function it_removes_saved_invoice_file_using_pdf_bundle_if_database_updat ->with($order, $invoiceDateTime) ->willReturn($invoice); - $this->invoicePdfFileGenerator - ->expects(self::once()) - ->method('generate') - ->with($invoice) - ->willReturn($invoicePdf); - - $pdfFileManager - ->expects(self::once()) - ->method('save') - ->with( - self::callback(fn (PdfFile $file) => $file->filename() === 'invoice.pdf'), - 'sylius_invoicing', - ); - $this->invoiceRepository ->expects(self::once()) ->method('add') ->with($invoice) ->willThrowException(new EntityNotFoundException()); + $this->invoicePdfFileGenerator + ->expects($this->never()) + ->method('generate'); + $pdfFileManager - ->expects(self::once()) - ->method('remove') - ->with('invoice.pdf', 'sylius_invoicing'); + ->expects($this->never()) + ->method('save'); + + $this->expectException(EntityNotFoundException::class); $creator('0000001', $invoiceDateTime); } diff --git a/tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php b/tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php index f04a331e..fc32ce68 100644 --- a/tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php +++ b/tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php @@ -21,8 +21,12 @@ use Sylius\Component\Resource\Factory\FactoryInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; use Sylius\InvoicingPlugin\Entity\InvoiceSequenceInterface; +use Sylius\InvoicingPlugin\Exception\SequenceScopeNotSupported; use Sylius\InvoicingPlugin\Generator\InvoiceNumberGenerator; use Sylius\InvoicingPlugin\Generator\SequentialInvoiceNumberGenerator; +use Sylius\InvoicingPlugin\Resolver\AnnuallySequenceScopeResolver; +use Sylius\InvoicingPlugin\Resolver\GlobalSequenceScopeResolver; +use Sylius\InvoicingPlugin\Resolver\MonthlySequenceScopeResolver; use Symfony\Component\Clock\ClockInterface; final class SequentialInvoiceNumberGeneratorTest extends TestCase @@ -45,14 +49,7 @@ protected function setUp(): void $this->sequenceManager = $this->createMock(EntityManagerInterface::class); $this->clock = $this->createMock(ClockInterface::class); - $this->generator = new SequentialInvoiceNumberGenerator( - $this->sequenceRepository, - $this->sequenceFactory, - $this->sequenceManager, - $this->clock, - 1, - 9, - ); + $this->generator = $this->createGenerator(InvoiceSequenceInterface::SCOPE_GLOBAL); } #[Test] @@ -69,7 +66,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(['type' => InvoiceSequenceInterface::SCOPE_GLOBAL, 'year' => 0, 'month' => 0]) + ->willReturn($sequence); $sequence->method('getVersion')->willReturn(1); $sequence->method('getIndex')->willReturn(0); @@ -96,9 +96,15 @@ 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(['type' => InvoiceSequenceInterface::SCOPE_GLOBAL, 'year' => 0, 'month' => 0]) + ->willReturn(null); $this->sequenceFactory->method('createNew')->willReturn($sequence); + $sequence->expects(self::once())->method('setType')->with(InvoiceSequenceInterface::SCOPE_GLOBAL); + $sequence->expects(self::once())->method('setYear')->with(0); + $sequence->expects(self::once())->method('setMonth')->with(0); $this->sequenceManager ->expects(self::once()) @@ -119,6 +125,173 @@ 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 = $this->createGenerator(InvoiceSequenceInterface::SCOPE_MONTHLY); + + $this->sequenceRepository + ->method('findOneBy') + ->with(['type' => InvoiceSequenceInterface::SCOPE_MONTHLY, 'year' => 2025, 'month' => 10]) + ->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 = $this->createGenerator(InvoiceSequenceInterface::SCOPE_ANNUALLY); + + $this->sequenceRepository + ->method('findOneBy') + ->with(['type' => InvoiceSequenceInterface::SCOPE_ANNUALLY, 'year' => 2025, 'month' => 0]) + ->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 = $this->createGenerator(InvoiceSequenceInterface::SCOPE_MONTHLY); + + $scope = InvoiceSequenceInterface::SCOPE_MONTHLY; + + $this->sequenceRepository + ->expects(self::once()) + ->method('findOneBy') + ->with(['type' => $scope, 'year' => 2025, 'month' => 10]) + ->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); + } + + #[Test] + public function it_throws_an_exception_when_no_resolver_supports_the_scope(): void + { + $this->clock->method('now')->willReturn(new \DateTimeImmutable('now')); + + $generator = $this->createGenerator('weekly'); + + $this->expectException(SequenceScopeNotSupported::class); + + $generator->generate(); + } + + #[Test] + public function it_falls_back_to_built_in_scope_resolvers_when_none_are_passed(): 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, + ); + + $this->sequenceRepository + ->method('findOneBy') + ->with(['type' => InvoiceSequenceInterface::SCOPE_GLOBAL, 'year' => 0, 'month' => 0]) + ->willReturn($sequence); + + $sequence->method('getVersion')->willReturn(1); + $sequence->method('getIndex')->willReturn(0); + + self::assertSame('2025/10/000000001', $generator->generate()); + } + + private function createGenerator(string $scope): SequentialInvoiceNumberGenerator + { + return new SequentialInvoiceNumberGenerator( + $this->sequenceRepository, + $this->sequenceFactory, + $this->sequenceManager, + $this->clock, + 1, + 9, + [ + new GlobalSequenceScopeResolver(), + new MonthlySequenceScopeResolver(), + new AnnuallySequenceScopeResolver(), + ], + $scope, + ); } } diff --git a/tests/Unit/Resolver/AnnuallySequenceScopeResolverTest.php b/tests/Unit/Resolver/AnnuallySequenceScopeResolverTest.php new file mode 100644 index 00000000..be0a9ebf --- /dev/null +++ b/tests/Unit/Resolver/AnnuallySequenceScopeResolverTest.php @@ -0,0 +1,51 @@ +supports(InvoiceSequenceInterface::SCOPE_ANNUALLY)); + self::assertFalse($resolver->supports(InvoiceSequenceInterface::SCOPE_GLOBAL)); + self::assertFalse($resolver->supports(InvoiceSequenceInterface::SCOPE_MONTHLY)); + } + + #[Test] + public function it_resolves_criteria_with_the_current_year_only(): void + { + $resolver = new AnnuallySequenceScopeResolver(); + + self::assertSame( + ['year' => 2025, 'month' => 0], + $resolver->resolve(new \DateTimeImmutable('2025-10-15')), + ); + } + + #[Test] + public function it_prefixes_numbers_with_the_issue_year_and_month(): void + { + $resolver = new AnnuallySequenceScopeResolver(); + + self::assertSame('2025/10/', $resolver->prefix(new \DateTimeImmutable('2025-10-15'))); + } +} diff --git a/tests/Unit/Resolver/GlobalSequenceScopeResolverTest.php b/tests/Unit/Resolver/GlobalSequenceScopeResolverTest.php new file mode 100644 index 00000000..aa62e3b8 --- /dev/null +++ b/tests/Unit/Resolver/GlobalSequenceScopeResolverTest.php @@ -0,0 +1,51 @@ +supports(InvoiceSequenceInterface::SCOPE_GLOBAL)); + self::assertFalse($resolver->supports(InvoiceSequenceInterface::SCOPE_MONTHLY)); + self::assertFalse($resolver->supports(InvoiceSequenceInterface::SCOPE_ANNUALLY)); + } + + #[Test] + public function it_resolves_criteria_independent_of_the_current_date(): void + { + $resolver = new GlobalSequenceScopeResolver(); + + self::assertSame( + ['year' => 0, 'month' => 0], + $resolver->resolve(new \DateTimeImmutable('2025-10-15')), + ); + } + + #[Test] + public function it_prefixes_numbers_with_the_issue_year_and_month(): void + { + $resolver = new GlobalSequenceScopeResolver(); + + self::assertSame('2025/10/', $resolver->prefix(new \DateTimeImmutable('2025-10-15'))); + } +} diff --git a/tests/Unit/Resolver/MonthlySequenceScopeResolverTest.php b/tests/Unit/Resolver/MonthlySequenceScopeResolverTest.php new file mode 100644 index 00000000..8c070759 --- /dev/null +++ b/tests/Unit/Resolver/MonthlySequenceScopeResolverTest.php @@ -0,0 +1,51 @@ +supports(InvoiceSequenceInterface::SCOPE_MONTHLY)); + self::assertFalse($resolver->supports(InvoiceSequenceInterface::SCOPE_GLOBAL)); + self::assertFalse($resolver->supports(InvoiceSequenceInterface::SCOPE_ANNUALLY)); + } + + #[Test] + public function it_resolves_criteria_with_the_current_year_and_month(): void + { + $resolver = new MonthlySequenceScopeResolver(); + + self::assertSame( + ['year' => 2025, 'month' => 10], + $resolver->resolve(new \DateTimeImmutable('2025-10-15')), + ); + } + + #[Test] + public function it_prefixes_numbers_with_the_issue_year_and_month(): void + { + $resolver = new MonthlySequenceScopeResolver(); + + self::assertSame('2025/10/', $resolver->prefix(new \DateTimeImmutable('2025-10-15'))); + } +} From fb2712a3a51f082c5a16536afa88bd5ebc104f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Mon, 20 Jul 2026 09:43:17 +0200 Subject: [PATCH 5/7] Add invoice number sequence configuration and context for Behat tests --- .../numbering_invoices_annually.feature | 35 +++++ .../numbering_invoices_globally.feature | 28 ++++ .../numbering_invoices_monthly.feature | 61 +++++++++ ...venting_duplicated_invoice_numbers.feature | 31 +++++ .../recovering_invoice_files.feature | 23 ++++ .../Context/Domain/InvoiceNumberContext.php | 120 ++++++++++++++++++ .../Context/Hook/SequenceScopeContext.php | 49 +++++++ tests/Behat/Context/Setup/SequenceContext.php | 39 ++++++ tests/Behat/Resources/services.xml | 28 ++++ .../suites/invoice_number_sequence.yml | 44 +++++++ tests/Behat/Resources/suites/suites.yml | 1 + .../SwitchableSequenceScopeResolver.php | 57 +++++++++ 12 files changed, 516 insertions(+) create mode 100644 features/managing_invoices/invoice_number_sequence/numbering_invoices_annually.feature create mode 100644 features/managing_invoices/invoice_number_sequence/numbering_invoices_globally.feature create mode 100644 features/managing_invoices/invoice_number_sequence/numbering_invoices_monthly.feature create mode 100644 features/managing_invoices/invoice_number_sequence/preventing_duplicated_invoice_numbers.feature create mode 100644 features/managing_invoices/invoice_number_sequence/recovering_invoice_files.feature create mode 100644 tests/Behat/Context/Domain/InvoiceNumberContext.php create mode 100644 tests/Behat/Context/Hook/SequenceScopeContext.php create mode 100644 tests/Behat/Context/Setup/SequenceContext.php create mode 100644 tests/Behat/Resources/suites/invoice_number_sequence.yml create mode 100644 tests/Behat/Service/SwitchableSequenceScopeResolver.php diff --git a/features/managing_invoices/invoice_number_sequence/numbering_invoices_annually.feature b/features/managing_invoices/invoice_number_sequence/numbering_invoices_annually.feature new file mode 100644 index 00000000..cd253c73 --- /dev/null +++ b/features/managing_invoices/invoice_number_sequence/numbering_invoices_annually.feature @@ -0,0 +1,35 @@ +@invoice_number_sequence @annually_sequence_scope @pdf_enabled +Feature: Numbering invoices with an annual sequence + In order to have invoice numbers restarting every year + As a Shop Owner + I want invoice numbers to be scoped to the year they are issued in + + Background: + Given the store operates on a single channel in "United States" + And the store has a product "Pascaline Calculator Replica" priced at "$60.00" + And the store has "UPS" shipping method with "$10.00" fee + And the store allows paying with "Cash on Delivery" + + @application + Scenario: Continuing numbering across months of the same year and restarting in a new year + Given it is "2025-11-20" now + And there is a customer "blaise.pascal@gmail.com" that placed an order "#00000001" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Blaise Pascal" addressed it to "Royal St", "70116" "New Orleans" in the "United States" + And for the billing address of "Gilberte Périer" in the "Chartres St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + And it is "2025-12-31" now + And there is another customer "pierre.fermat@gmail.com" that placed an order "#00000002" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Pierre de Fermat" addressed it to "Toulouse St", "70116" "New Orleans" in the "United States" + And for the billing address of "Pierre de Fermat" in the "Toulouse St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + And it is "2026-01-01" now + And there is another customer "christiaan.huygens@gmail.com" that placed an order "#00000003" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Christiaan Huygens" addressed it to "Dauphine St", "70116" "New Orleans" in the "United States" + And for the billing address of "Christiaan Huygens" in the "Dauphine St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + Then the invoice for order "#00000001" should have number "2025/11/000000001" + And the invoice for order "#00000002" should have number "2025/12/000000002" + And the invoice for order "#00000003" should have number "2026/01/000000001" diff --git a/features/managing_invoices/invoice_number_sequence/numbering_invoices_globally.feature b/features/managing_invoices/invoice_number_sequence/numbering_invoices_globally.feature new file mode 100644 index 00000000..3fd25c67 --- /dev/null +++ b/features/managing_invoices/invoice_number_sequence/numbering_invoices_globally.feature @@ -0,0 +1,28 @@ +@invoice_number_sequence @global_sequence_scope @pdf_enabled +Feature: Numbering invoices with a global sequence + In order to have gapless, ever-increasing invoice numbers + As a Shop Owner + I want invoice numbers to keep incrementing regardless of the passing months and years + + Background: + Given the store operates on a single channel in "United States" + And the store has a product "ES-335 Semi-Hollow Guitar" priced at "$60.00" + And the store has "UPS" shipping method with "$10.00" fee + And the store allows paying with "Cash on Delivery" + + @application + Scenario: Continuing numbering across months and years + Given it is "2025-12-20" now + And there is a customer "bb.king@gmail.com" that placed an order "#00000001" + And the customer bought a single "ES-335 Semi-Hollow Guitar" + And the customer "B.B. King" addressed it to "Beale St", "38103" "Memphis" in the "United States" + And for the billing address of "Lucille King" in the "Union Ave", "38103" "Memphis", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + And it is "2026-01-05" now + And there is another customer "chuck.berry@gmail.com" that placed an order "#00000002" + And the customer bought a single "ES-335 Semi-Hollow Guitar" + And the customer "Chuck Berry" addressed it to "Gibson Ave", "49001" "Kalamazoo" in the "United States" + And for the billing address of "Chuck Berry" in the "Gibson Ave", "49001" "Kalamazoo", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + Then the invoice for order "#00000001" should have number "2025/12/000000001" + And the invoice for order "#00000002" should have number "2026/01/000000002" diff --git a/features/managing_invoices/invoice_number_sequence/numbering_invoices_monthly.feature b/features/managing_invoices/invoice_number_sequence/numbering_invoices_monthly.feature new file mode 100644 index 00000000..9675a631 --- /dev/null +++ b/features/managing_invoices/invoice_number_sequence/numbering_invoices_monthly.feature @@ -0,0 +1,61 @@ +@invoice_number_sequence @monthly_sequence_scope @pdf_enabled +Feature: Numbering invoices with a monthly sequence + In order to have invoice numbers restarting every month + As a Shop Owner + I want invoice numbers to be scoped to the month they are issued in + + Background: + Given the store operates on a single channel in "United States" + And the store has a product "Pascaline Calculator Replica" priced at "$60.00" + And the store has "UPS" shipping method with "$10.00" fee + And the store allows paying with "Cash on Delivery" + + @application + Scenario: Incrementing numbers within the same month + Given it is "2025-10-15" now + And there is a customer "blaise.pascal@gmail.com" that placed an order "#00000001" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Blaise Pascal" addressed it to "Royal St", "70116" "New Orleans" in the "United States" + And for the billing address of "Gilberte Périer" in the "Chartres St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + And there is another customer "pierre.fermat@gmail.com" that placed an order "#00000002" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Pierre de Fermat" addressed it to "Toulouse St", "70116" "New Orleans" in the "United States" + And for the billing address of "Pierre de Fermat" in the "Toulouse St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + Then the invoice for order "#00000001" should have number "2025/10/000000001" + And the invoice for order "#00000002" should have number "2025/10/000000002" + + @application + Scenario: Restarting numbering in a new month + Given it is "2025-10-15" now + And there is a customer "blaise.pascal@gmail.com" that placed an order "#00000001" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Blaise Pascal" addressed it to "Royal St", "70116" "New Orleans" in the "United States" + And for the billing address of "Gilberte Périer" in the "Chartres St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + And it is "2025-11-03" now + And there is another customer "pierre.fermat@gmail.com" that placed an order "#00000002" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Pierre de Fermat" addressed it to "Toulouse St", "70116" "New Orleans" in the "United States" + And for the billing address of "Pierre de Fermat" in the "Toulouse St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + Then the invoice for order "#00000001" should have number "2025/10/000000001" + And the invoice for order "#00000002" should have number "2025/11/000000001" + + @application + Scenario: Restarting numbering across a year boundary + Given it is "2025-12-31" now + And there is a customer "blaise.pascal@gmail.com" that placed an order "#00000001" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Blaise Pascal" addressed it to "Royal St", "70116" "New Orleans" in the "United States" + And for the billing address of "Gilberte Périer" in the "Chartres St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + And it is "2026-01-01" now + And there is another customer "pierre.fermat@gmail.com" that placed an order "#00000002" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Pierre de Fermat" addressed it to "Toulouse St", "70116" "New Orleans" in the "United States" + And for the billing address of "Pierre de Fermat" in the "Toulouse St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + Then the invoice for order "#00000001" should have number "2025/12/000000001" + And the invoice for order "#00000002" should have number "2026/01/000000001" diff --git a/features/managing_invoices/invoice_number_sequence/preventing_duplicated_invoice_numbers.feature b/features/managing_invoices/invoice_number_sequence/preventing_duplicated_invoice_numbers.feature new file mode 100644 index 00000000..650f3f7b --- /dev/null +++ b/features/managing_invoices/invoice_number_sequence/preventing_duplicated_invoice_numbers.feature @@ -0,0 +1,31 @@ +@invoice_number_sequence @global_sequence_scope @pdf_enabled +Feature: Preventing duplicated invoice numbers + In order to never expose an invoice of one customer to another + As a Shop Owner + I want invoice generation to fail loudly when it would reuse an already issued invoice number + + Background: + Given the store operates on a single channel in "United States" + And the store has a product "Pascaline Calculator Replica" priced at "$60.00" + And the store has "UPS" shipping method with "$10.00" fee + And the store allows paying with "Cash on Delivery" + And it is "2025-10-15" now + And there is a customer "blaise.pascal@gmail.com" that placed an order "#00000001" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Blaise Pascal" addressed it to "Royal St", "70116" "New Orleans" in the "United States" + And for the billing address of "Gilberte Périer" in the "Chartres St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + And there is another customer "pierre.fermat@gmail.com" that placed an order "#00000002" + And the customer bought a single "Pascaline Calculator Replica" + And the customer "Pierre de Fermat" addressed it to "Toulouse St", "70116" "New Orleans" in the "United States" + And for the billing address of "Pierre de Fermat" in the "Toulouse St", "70116" "New Orleans", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + + @application + Scenario: Failing loudly instead of reusing an already issued invoice number + Given the order "#00000002" has lost all of its invoices + And the invoice number sequences have been reset + Then it should not be possible to generate an invoice for order "#00000002" + And the order "#00000002" should have no invoice + And the invoice for order "#00000001" should have number "2025/10/000000001" + And the invoice for order "#00000001" should be saved on the server diff --git a/features/managing_invoices/invoice_number_sequence/recovering_invoice_files.feature b/features/managing_invoices/invoice_number_sequence/recovering_invoice_files.feature new file mode 100644 index 00000000..0f661eba --- /dev/null +++ b/features/managing_invoices/invoice_number_sequence/recovering_invoice_files.feature @@ -0,0 +1,23 @@ +@invoice_number_sequence @global_sequence_scope @pdf_enabled +Feature: Recovering lost invoice files + In order to always hand invoices out to customers + As a Shop Owner + I want invoice files to be regenerated from the database when they are missing from the storage + + Background: + Given the store operates on a single channel in "United States" + And the store has a product "ES-335 Semi-Hollow Guitar" priced at "$60.00" + And the store has "UPS" shipping method with "$10.00" fee + And the store allows paying with "Cash on Delivery" + And it is "2025-10-15" now + And there is a customer "bb.king@gmail.com" that placed an order "#00000001" + And the customer bought a single "ES-335 Semi-Hollow Guitar" + And the customer "B.B. King" addressed it to "Beale St", "38103" "Memphis" in the "United States" + And for the billing address of "Lucille King" in the "Union Ave", "38103" "Memphis", "United States" + And the customer chose "UPS" shipping method with "Cash on Delivery" payment + + @application + Scenario: Regenerating a lost invoice file on download + Given the invoice file for order "#00000001" has been removed from the server + Then the invoice for order "#00000001" should be downloadable with number "2025/10/000000001" + And the invoice for order "#00000001" should be saved on the server diff --git a/tests/Behat/Context/Domain/InvoiceNumberContext.php b/tests/Behat/Context/Domain/InvoiceNumberContext.php new file mode 100644 index 00000000..0aa3f77a --- /dev/null +++ b/tests/Behat/Context/Domain/InvoiceNumberContext.php @@ -0,0 +1,120 @@ +getInvoiceForOrder($order)->number(), $number); + } + + /** + * @Then the order :order should have no invoice + */ + public function theOrderShouldHaveNoInvoice(OrderInterface $order): void + { + Assert::null($this->invoiceRepository->findOneByOrder($order)); + } + + /** + * @Then it should not be possible to generate an invoice for order :order + */ + public function itShouldNotBePossibleToGenerateAnInvoiceForOrder(OrderInterface $order): void + { + $orderNumber = (string) $order->getNumber(); + + try { + ($this->invoiceCreator)($orderNumber, $this->clock->now()); + } catch (\Throwable) { + $this->managerRegistry->resetManager(); + + return; + } + + throw new \DomainException(sprintf( + 'Generating an invoice for order %s was expected to fail, but it succeeded', + $orderNumber, + )); + } + + /** + * @Given the invoice file for order :order has been removed from the server + */ + public function theInvoiceFileForOrderHasBeenRemovedFromTheServer(OrderInterface $order): void + { + $filePath = $this->getInvoiceFilePath($this->getInvoiceForOrder($order)); + + Assert::true(file_exists($filePath), sprintf('Expected the invoice file "%s" to exist', $filePath)); + + unlink($filePath); + } + + /** + * @Then the invoice for order :order should be downloadable with number :number + */ + public function theInvoiceForOrderShouldBeDownloadableWithNumber(OrderInterface $order, string $number): void + { + $invoice = $this->getInvoiceForOrder($order); + Assert::same($invoice->number(), $number); + + $invoicePdf = $this->invoiceFileProvider->provide($invoice); + + Assert::same($invoicePdf->filename(), $this->invoiceFileNameGenerator->generateForPdf($invoice)); + Assert::notEmpty($invoicePdf->content()); + Assert::true(file_exists($this->getInvoiceFilePath($invoice))); + } + + private function getInvoiceForOrder(OrderInterface $order): InvoiceInterface + { + $invoice = $this->invoiceRepository->findOneByOrder($order); + + Assert::isInstanceOf($invoice, InvoiceInterface::class, sprintf( + 'No invoice has been generated for order %s', + $order->getNumber(), + )); + + return $invoice; + } + + private function getInvoiceFilePath(InvoiceInterface $invoice): string + { + return rtrim($this->invoicesSavePath, '/') . '/' . $this->invoiceFileNameGenerator->generateForPdf($invoice); + } +} diff --git a/tests/Behat/Context/Hook/SequenceScopeContext.php b/tests/Behat/Context/Hook/SequenceScopeContext.php new file mode 100644 index 00000000..c9a62886 --- /dev/null +++ b/tests/Behat/Context/Hook/SequenceScopeContext.php @@ -0,0 +1,49 @@ +sequenceScopeResolver->switchTo(InvoiceSequenceInterface::SCOPE_MONTHLY); + } + + /** + * @BeforeScenario @annually_sequence_scope + */ + public function enableAnnuallySequenceScope(): void + { + $this->sequenceScopeResolver->switchTo(InvoiceSequenceInterface::SCOPE_ANNUALLY); + } + + /** + * @AfterScenario + */ + public function resetSequenceScope(): void + { + $this->sequenceScopeResolver->switchTo(null); + } +} diff --git a/tests/Behat/Context/Setup/SequenceContext.php b/tests/Behat/Context/Setup/SequenceContext.php new file mode 100644 index 00000000..66b73514 --- /dev/null +++ b/tests/Behat/Context/Setup/SequenceContext.php @@ -0,0 +1,39 @@ +sequenceRepository->findAll() as $sequence) { + $this->sequenceManager->remove($sequence); + } + + $this->sequenceManager->flush(); + } +} diff --git a/tests/Behat/Resources/services.xml b/tests/Behat/Resources/services.xml index 6d1a011d..17d8a185 100644 --- a/tests/Behat/Resources/services.xml +++ b/tests/Behat/Resources/services.xml @@ -66,6 +66,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + %sylius_invoicing.invoice_save_path% + + %sylius_invoicing.invoice_save_path% diff --git a/tests/Behat/Resources/suites/invoice_number_sequence.yml b/tests/Behat/Resources/suites/invoice_number_sequence.yml new file mode 100644 index 00000000..bddffad8 --- /dev/null +++ b/tests/Behat/Resources/suites/invoice_number_sequence.yml @@ -0,0 +1,44 @@ +default: + suites: + invoice_number_sequence: + contexts: + - sylius.behat.context.hook.doctrine_orm + - sylius.behat.context.hook.calendar + - Tests\Sylius\InvoicingPlugin\Behat\Context\Hook\InvoicesContext + - Tests\Sylius\InvoicingPlugin\Behat\Context\Hook\SequenceScopeContext + + - sylius.behat.context.setup.calendar + - sylius.behat.context.setup.channel + - sylius.behat.context.setup.currency + - sylius.behat.context.setup.customer + - sylius.behat.context.setup.geographical + - sylius.behat.context.setup.order + - sylius.behat.context.setup.payment + - sylius.behat.context.setup.product + - sylius.behat.context.setup.shipping + - sylius.behat.context.setup.taxation + - sylius.behat.context.setup.zone + - Tests\Sylius\InvoicingPlugin\Behat\Context\Setup\ChannelContext + - Tests\Sylius\InvoicingPlugin\Behat\Context\Setup\SequenceContext + + - sylius.behat.context.transform.address + - sylius.behat.context.transform.channel + - sylius.behat.context.transform.country + - sylius.behat.context.transform.currency + - sylius.behat.context.transform.customer + - sylius.behat.context.transform.lexical + - sylius.behat.context.transform.order + - sylius.behat.context.transform.payment + - sylius.behat.context.transform.product + - sylius.behat.context.transform.product_variant + - sylius.behat.context.transform.shipping_method + - sylius.behat.context.transform.tax_category + - sylius.behat.context.transform.taxon + - sylius.behat.context.transform.zone + - sylius.behat.context.transform.shared_storage + + - Tests\Sylius\InvoicingPlugin\Behat\Context\Domain\GeneratingInvoiceContext + - Tests\Sylius\InvoicingPlugin\Behat\Context\Domain\InvoiceNumberContext + - Tests\Sylius\InvoicingPlugin\Behat\Context\Application\ManagingInvoicesContext + filters: + tags: "@invoice_number_sequence" diff --git a/tests/Behat/Resources/suites/suites.yml b/tests/Behat/Resources/suites/suites.yml index 51e405ad..b5f20459 100644 --- a/tests/Behat/Resources/suites/suites.yml +++ b/tests/Behat/Resources/suites/suites.yml @@ -1,3 +1,4 @@ imports: - admin/managing_invoices.yml - customer.yml + - invoice_number_sequence.yml diff --git a/tests/Behat/Service/SwitchableSequenceScopeResolver.php b/tests/Behat/Service/SwitchableSequenceScopeResolver.php new file mode 100644 index 00000000..7101f2a9 --- /dev/null +++ b/tests/Behat/Service/SwitchableSequenceScopeResolver.php @@ -0,0 +1,57 @@ + $decoratedResolvers */ + public function __construct(private readonly iterable $decoratedResolvers) + { + } + + public function switchTo(?string $scope): void + { + $this->scope = $scope; + } + + public function supports(string $scope): bool + { + return null !== $this->scope; + } + + public function resolve(\DateTimeImmutable $now): array + { + return $this->getResolver()->resolve($now); + } + + public function prefix(\DateTimeImmutable $now): string + { + return $this->getResolver()->prefix($now); + } + + private function getResolver(): SequenceScopeResolverInterface + { + foreach ($this->decoratedResolvers as $resolver) { + if ($resolver->supports((string) $this->scope)) { + return $resolver; + } + } + + throw new \RuntimeException(sprintf('No sequence scope resolver supports the "%s" scope', $this->scope)); + } +} From df38698bc23c540593ef9572635c043195ee2c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Mon, 20 Jul 2026 11:00:58 +0200 Subject: [PATCH 6/7] ECS fixes --- .../Application/ManagingInvoicesContext.php | 12 ++++- .../Context/Cli/InvoicesGenerationContext.php | 15 ++++-- .../Context/Domain/InvoiceEmailContext.php | 11 ++++- tests/Behat/Context/Hook/InvoicesContext.php | 13 ++++- tests/Behat/Context/Order/OrderContext.php | 9 ++++ tests/Behat/Context/Setup/ChannelContext.php | 11 ++++- .../Ui/Admin/ManagingChannelsContext.php | 13 ++++- .../Ui/Admin/ManagingInvoicesContext.php | 47 ++++++++++++++----- .../Shop/CustomerBrowsingInvoicesContext.php | 11 ++++- tests/Behat/Page/Admin/Channel/UpdatePage.php | 9 ++++ .../Admin/Channel/UpdatePageInterface.php | 9 ++++ tests/Behat/Page/Admin/Invoice/IndexPage.php | 11 ++++- .../Page/Admin/Invoice/IndexPageInterface.php | 9 ++++ tests/Behat/Page/Admin/Invoice/ShowPage.php | 21 ++++++--- .../Page/Admin/Invoice/ShowPageInterface.php | 25 ++++++---- tests/Behat/Page/Admin/Order/ShowPage.php | 9 ++++ .../Page/Admin/Order/ShowPageInterface.php | 9 ++++ .../Page/Shop/Order/DownloadInvoicePage.php | 9 ++++ .../Order/DownloadInvoicePageInterface.php | 10 +++- tests/Behat/Page/Shop/Order/ShowPage.php | 9 ++++ 20 files changed, 232 insertions(+), 40 deletions(-) diff --git a/tests/Behat/Context/Application/ManagingInvoicesContext.php b/tests/Behat/Context/Application/ManagingInvoicesContext.php index 765d8128..deed9aa7 100644 --- a/tests/Behat/Context/Application/ManagingInvoicesContext.php +++ b/tests/Behat/Context/Application/ManagingInvoicesContext.php @@ -1,11 +1,19 @@ invoiceRepository->findOneByOrder($order); - $filePath = $this->invoicesSavePath.'/'.str_replace('/', '_', $invoice->number()).'.pdf'; + $filePath = $this->invoicesSavePath . '/' . str_replace('/', '_', $invoice->number()) . '.pdf'; Assert::true(file_exists($filePath)); } diff --git a/tests/Behat/Context/Cli/InvoicesGenerationContext.php b/tests/Behat/Context/Cli/InvoicesGenerationContext.php index 81664f19..5e289919 100644 --- a/tests/Behat/Context/Cli/InvoicesGenerationContext.php +++ b/tests/Behat/Context/Cli/InvoicesGenerationContext.php @@ -1,5 +1,14 @@ kernel = $kernel; $this->massInvoicesCreator = $massInvoicesCreator; @@ -46,8 +55,8 @@ public function generateInvoicesForPreviouslyPlacedOrders(): void $application->add( new GenerateInvoicesCommand( $this->massInvoicesCreator, - $this->orderRepository - ) + $this->orderRepository, + ), ); /** @var Command $command */ diff --git a/tests/Behat/Context/Domain/InvoiceEmailContext.php b/tests/Behat/Context/Domain/InvoiceEmailContext.php index c3684bfc..084b5bad 100644 --- a/tests/Behat/Context/Domain/InvoiceEmailContext.php +++ b/tests/Behat/Context/Domain/InvoiceEmailContext.php @@ -1,5 +1,14 @@ emailChecker->hasMessageTo( sprintf('was generated for order with number %s', $orderNumber), - $recipient + $recipient, )); } } diff --git a/tests/Behat/Context/Hook/InvoicesContext.php b/tests/Behat/Context/Hook/InvoicesContext.php index 08c161d3..bad95c32 100644 --- a/tests/Behat/Context/Hook/InvoicesContext.php +++ b/tests/Behat/Context/Hook/InvoicesContext.php @@ -1,5 +1,14 @@ invoicesSavePath) as $file) { - if (is_file($this->invoicesSavePath.'/'.$file)) { - unlink($this->invoicesSavePath.'/'.$file); + if (is_file($this->invoicesSavePath . '/' . $file)) { + unlink($this->invoicesSavePath . '/' . $file); } } } diff --git a/tests/Behat/Context/Order/OrderContext.php b/tests/Behat/Context/Order/OrderContext.php index c815bff2..10fdce48 100644 --- a/tests/Behat/Context/Order/OrderContext.php +++ b/tests/Behat/Context/Order/OrderContext.php @@ -1,5 +1,14 @@ setCity($city); diff --git a/tests/Behat/Context/Ui/Admin/ManagingChannelsContext.php b/tests/Behat/Context/Ui/Admin/ManagingChannelsContext.php index 087313a6..905d4cb5 100644 --- a/tests/Behat/Context/Ui/Admin/ManagingChannelsContext.php +++ b/tests/Behat/Context/Ui/Admin/ManagingChannelsContext.php @@ -1,5 +1,14 @@ updatePage->specifyBillingAddress($street, $postcode, $city, $country->getCode()); } @@ -69,7 +78,7 @@ public function thisChannelShopBillingAddressShouldBe( string $street, string $postcode, string $city, - CountryInterface $country + CountryInterface $country, ): void { Assert::true($this->updatePage->hasBillingAddress($street, $postcode, $city, $country->getCode())); } diff --git a/tests/Behat/Context/Ui/Admin/ManagingInvoicesContext.php b/tests/Behat/Context/Ui/Admin/ManagingInvoicesContext.php index 8d4b67b7..21995a42 100644 --- a/tests/Behat/Context/Ui/Admin/ManagingInvoicesContext.php +++ b/tests/Behat/Context/Ui/Admin/ManagingInvoicesContext.php @@ -1,5 +1,14 @@ indexPage = $indexPage; $this->showPage = $showPage; @@ -99,7 +108,7 @@ public function viewSummaryOfInvoiceForOrder(OrderInterface $order): void public function shouldBeIssuedInTheLastHour(): void { Assert::true( - ((new \DateTimeImmutable('now'))->getTimestamp() - $this->showPage->getIssuedAtDate()->getTimestamp()) <= 3600 + ((new \DateTimeImmutable('now'))->getTimestamp() - $this->showPage->getIssuedAtDate()->getTimestamp()) <= 3600, ); } @@ -160,7 +169,7 @@ public function itShouldHaveBillingDataAs( string $street, string $postcode, string $city, - string $countryName + string $countryName, ): void { Assert::true($this->showPage->hasBillingData($customerName, $street, $postcode, $city, $countryName)); } @@ -175,7 +184,7 @@ public function itShouldHaveShopBillingDataAs( string $countryName, string $street, string $postcode, - string $city + string $city, ): void { Assert::true($this->showPage->hasShopBillingData($company, $taxId, $countryName, $street, $city, $postcode)); } @@ -196,10 +205,10 @@ public function itShouldHaveAnItemWithData( string $unitPrice, int $quantity, string $taxTotal, - string $total + string $total, ): void { Assert::true( - $this->showPage->hasItemWithData(sprintf('%s (%s)', $name, $name), $unitPrice, $unitPrice, $quantity, $taxTotal, $total) + $this->showPage->hasItemWithData(sprintf('%s (%s)', $name, $name), $unitPrice, $unitPrice, $quantity, $taxTotal, $total), ); } @@ -243,7 +252,7 @@ public function itShouldHaveAShippingItemWithData( string $unitPrice, int $quantity, string $taxTotal, - string $total + string $total, ): void { Assert::true($this->showPage->hasItemWithData($name, $unitPrice, $unitPrice, $quantity, $taxTotal, $total)); } @@ -295,7 +304,7 @@ public function shouldBeNotifiedThatEmailWasSentSuccessfully(): void { $this->notificationChecker->checkNotification( 'Invoice has been successfully resent to the customer', - NotificationType::success() + NotificationType::success(), ); } @@ -310,10 +319,17 @@ public function itShouldHaveShipmentWithUnitNetPriceDiscountedUnitPriceNetValueT string $netValue, string $taxTotal, string $total, - string $currencyCode + string $currencyCode, ): void { Assert::true($this->showPage->hasItemWithData( - $name, $unitNetPrice, $discountedUnitNetPrice, $quantity, $taxTotal, $total, $currencyCode, $netValue + $name, + $unitNetPrice, + $discountedUnitNetPrice, + $quantity, + $taxTotal, + $total, + $currencyCode, + $netValue, )); } @@ -329,10 +345,17 @@ public function itShouldHaveItemsWithUnitNetPriceDiscountedUnitPriceNetValueTaxT string $netValue, string $taxTotal, string $total, - string $currencyCode + string $currencyCode, ): void { Assert::true($this->showPage->hasItemWithData( - $name, $unitNetPrice, $discountedUnitNetPrice, $quantity, $taxTotal, $total, $currencyCode, $netValue + $name, + $unitNetPrice, + $discountedUnitNetPrice, + $quantity, + $taxTotal, + $total, + $currencyCode, + $netValue, )); } diff --git a/tests/Behat/Context/Ui/Shop/CustomerBrowsingInvoicesContext.php b/tests/Behat/Context/Ui/Shop/CustomerBrowsingInvoicesContext.php index 1f2dafdd..20ff795f 100644 --- a/tests/Behat/Context/Ui/Shop/CustomerBrowsingInvoicesContext.php +++ b/tests/Behat/Context/Ui/Shop/CustomerBrowsingInvoicesContext.php @@ -1,5 +1,14 @@ orderShowPage = $orderShowPage; $this->downloadInvoicePage = $downloadInvoicePage; diff --git a/tests/Behat/Page/Admin/Channel/UpdatePage.php b/tests/Behat/Page/Admin/Channel/UpdatePage.php index 27336092..27449339 100644 --- a/tests/Behat/Page/Admin/Channel/UpdatePage.php +++ b/tests/Behat/Page/Admin/Channel/UpdatePage.php @@ -1,5 +1,14 @@ getDocument()->findAll('css', 'table tbody tr')[$index-1]; + $invoice = $this->getDocument()->findAll('css', 'table tbody tr')[$index - 1]; return $invoice->find('css', sprintf('td:contains("%s")', $channel)) !== null; } diff --git a/tests/Behat/Page/Admin/Invoice/IndexPageInterface.php b/tests/Behat/Page/Admin/Invoice/IndexPageInterface.php index 5565109c..78789f51 100644 --- a/tests/Behat/Page/Admin/Invoice/IndexPageInterface.php +++ b/tests/Behat/Page/Admin/Invoice/IndexPageInterface.php @@ -1,5 +1,14 @@ getElement('billing_address')->getText(); @@ -59,7 +68,7 @@ public function hasShopBillingData( string $countryName, string $street, string $city, - string $postcode + string $postcode, ): bool { $billingDataText = $this->getElement('shop_billing_data')->getText(); @@ -84,8 +93,8 @@ public function hasItemWithData( int $quantity, string $taxTotal, string $total, - string $currencyCode = null, - string $netValue = null + ?string $currencyCode = null, + ?string $netValue = null, ): bool { $row = $this->tableAccessor->getRowsWithFields($this->getElement('table'), [ 'name' => $name, @@ -99,7 +108,7 @@ public function hasItemWithData( return null !== $row; } - public function hasTaxItem(string $label, string $amount, string $currencyCode): bool + public function hasTaxItem(string $label, string $amount, string $currencyCode): bool { foreach ($this->getDocument()->findAll('css', '[data-test-invoice-tax-item]') as $item) { if ( diff --git a/tests/Behat/Page/Admin/Invoice/ShowPageInterface.php b/tests/Behat/Page/Admin/Invoice/ShowPageInterface.php index 34861199..2debfafb 100644 --- a/tests/Behat/Page/Admin/Invoice/ShowPageInterface.php +++ b/tests/Behat/Page/Admin/Invoice/ShowPageInterface.php @@ -1,5 +1,14 @@ Date: Mon, 20 Jul 2026 11:01:21 +0200 Subject: [PATCH 7/7] Add unique indexes for invoice number and sequence scope in MySQL and PostgreSQL migrations --- UPGRADE-2.3.md | 19 ++++++++++-- src/Migrations/Version20260717121814.php | 13 ++++++-- src/Migrations/Version20260720100000.php | 39 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 src/Migrations/Version20260720100000.php diff --git a/UPGRADE-2.3.md b/UPGRADE-2.3.md index 30895ced..eafd164a 100644 --- a/UPGRADE-2.3.md +++ b/UPGRADE-2.3.md @@ -9,17 +9,30 @@ scope: global # one of "global", "monthly", "annually" ``` + - `global` — a single, ever-increasing counter for the whole store, never reset. + - `monthly` — a separate counter per year and month, reset on the 1st of every month. + - `annually` — a separate counter per year, reset on the 1st of January. + + All three produce the same `Y/m/index` number format; only the counter's reset behavior differs. + Custom scopes can be added by registering a service implementing `Sylius\InvoicingPlugin\Resolver\SequenceScopeResolverInterface`, tagged with `sylius_invoicing.sequence_scope_resolver`, and setting its name as the `scope` option. Keep in mind that the number prefix returned by `prefix()` must make invoice numbers unique across all periods of the scope, as invoice files are stored under names derived from invoice numbers. + Changing the `scope` on a store that already has invoices is not risk-free: each scope keeps + its own counter, so switching scopes does not carry over or reset any existing counter — a fresh + one is started instead. If the new scope's counter produces a number that was already issued + under the previous scope for the current period, invoice generation will fail on the database's + unique constraint. To avoid this, only change the `scope` at the very start of a new period (e.g. + right after midnight on the 1st of a month), before any invoice has been issued in it. + 1. Run doctrine migrations when upgrading — the `sylius_invoicing_plugin_sequence` table gains `type`, `year` and `month` columns, and unique indexes are created on the sequence scope and on - the invoice number. In the unlikely case your database contains duplicated invoice numbers, - the migration will fail and the duplicates have to be resolved manually first. You can check - for duplicates upfront with: + the invoice number. Migrations are provided for both MySQL and PostgreSQL. In the unlikely case + your database contains duplicated invoice numbers, the migration will fail and the duplicates + have to be resolved manually first. You can check for duplicates upfront with: ```sql SELECT number, COUNT(*) FROM sylius_invoicing_plugin_invoice GROUP BY number HAVING COUNT(*) > 1; diff --git a/src/Migrations/Version20260717121814.php b/src/Migrations/Version20260717121814.php index 5bcd177e..7fb97c25 100644 --- a/src/Migrations/Version20260717121814.php +++ b/src/Migrations/Version20260717121814.php @@ -1,17 +1,26 @@ addSql('CREATE UNIQUE INDEX UNIQ_SYLIUS_INVOICING_INVOICE_NUMBER ON sylius_invoicing_plugin_invoice (number)'); + $this->addSql('ALTER TABLE sylius_invoicing_plugin_sequence ADD COLUMN year INT DEFAULT 0 NOT NULL, ADD COLUMN month INT DEFAULT 0 NOT NULL, ADD COLUMN type VARCHAR(255) DEFAULT \'global\' NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_SYLIUS_INVOICING_SEQUENCE_SCOPE ON sylius_invoicing_plugin_sequence (type, year, month)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX UNIQ_SYLIUS_INVOICING_INVOICE_NUMBER'); + $this->addSql('DROP INDEX UNIQ_SYLIUS_INVOICING_SEQUENCE_SCOPE'); + $this->addSql('ALTER TABLE sylius_invoicing_plugin_sequence DROP COLUMN year, DROP COLUMN month, DROP COLUMN type'); + } +}