diff --git a/UPGRADE-2.3.md b/UPGRADE-2.3.md
new file mode 100644
index 00000000..eafd164a
--- /dev/null
+++ b/UPGRADE-2.3.md
@@ -0,0 +1,67 @@
+# 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"
+ ```
+
+ - `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. 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;
+ ```
+
+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 ed04a5e4..12594094 100644
--- a/config/doctrine/InvoiceSequence.orm.xml
+++ b/config/doctrine/InvoiceSequence.orm.xml
@@ -6,11 +6,30 @@
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/services/generators.xml b/config/services/generators.xml
index 2ed6f149..10fe0c9f 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/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/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/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index 9389a97c..250490e2 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -45,10 +45,29 @@ 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()
+ ->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()
+ ->end()
+ ;
+ }
+
private function addResourcesSection(ArrayNodeDefinition $node): void
{
$node
diff --git a/src/DependencyInjection/SyliusInvoicingExtension.php b/src/DependencyInjection/SyliusInvoicingExtension.php
index 46bd0681..99547ffc 100644
--- a/src/DependencyInjection/SyliusInvoicingExtension.php
+++ b/src/DependencyInjection/SyliusInvoicingExtension.php
@@ -41,6 +41,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', $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..010c8c10 100644
--- a/src/Entity/InvoiceSequence.php
+++ b/src/Entity/InvoiceSequence.php
@@ -23,6 +23,12 @@ class InvoiceSequence implements InvoiceSequenceInterface
protected ?int $version = 1;
+ protected string $type = InvoiceSequenceInterface::SCOPE_GLOBAL;
+
+ protected int $year = 0;
+
+ protected int $month = 0;
+
/** @return mixed */
public function getId()
{
@@ -48,4 +54,34 @@ public function setVersion(?int $version): void
{
$this->version = $version;
}
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function setType(string $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..8e5afb8a 100644
--- a/src/Entity/InvoiceSequenceInterface.php
+++ b/src/Entity/InvoiceSequenceInterface.php
@@ -18,7 +18,25 @@
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(): string;
+
+ public function setType(string $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/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 @@
+ */
+ private readonly iterable $scopeResolvers;
+
+ /** @param iterable|null $scopeResolvers */
public function __construct(
private readonly RepositoryInterface $sequenceRepository,
private readonly FactoryInterface $sequenceFactory,
@@ -29,22 +38,40 @@ public function __construct(
private readonly ClockInterface $clock,
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
@@ -54,19 +81,37 @@ 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
{
- /** @var InvoiceSequenceInterface $sequence */
- $sequence = $this->sequenceRepository->findOneBy([]);
+ $criteria = array_merge(['type' => $this->scope], $scopeCriteria);
+
+ /** @var InvoiceSequenceInterface|null $sequence */
+ $sequence = $this->sequenceRepository->findOneBy($criteria);
- if (null != $sequence) {
+ 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;
}
+
+ private function getScopeResolver(): SequenceScopeResolverInterface
+ {
+ foreach ($this->scopeResolvers as $scopeResolver) {
+ if ($scopeResolver->supports($this->scope)) {
+ return $scopeResolver;
+ }
+ }
+
+ throw SequenceScopeNotSupported::withScope($this->scope);
+ }
}
diff --git a/src/Migrations/Version20260717121814.php b/src/Migrations/Version20260717121814.php
new file mode 100644
index 00000000..7fb97c25
--- /dev/null
+++ b/src/Migrations/Version20260717121814.php
@@ -0,0 +1,39 @@
+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/Migrations/Version20260720100000.php b/src/Migrations/Version20260720100000.php
new file mode 100644
index 00000000..c438699c
--- /dev/null
+++ b/src/Migrations/Version20260720100000.php
@@ -0,0 +1,39 @@
+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');
+ }
+}
diff --git a/src/Resolver/AnnuallySequenceScopeResolver.php b/src/Resolver/AnnuallySequenceScopeResolver.php
new file mode 100644
index 00000000..7c5ba6b8
--- /dev/null
+++ b/src/Resolver/AnnuallySequenceScopeResolver.php
@@ -0,0 +1,34 @@
+ (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
new file mode 100644
index 00000000..842fad2a
--- /dev/null
+++ b/src/Resolver/GlobalSequenceScopeResolver.php
@@ -0,0 +1,34 @@
+ 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
new file mode 100644
index 00000000..026707e2
--- /dev/null
+++ b/src/Resolver/MonthlySequenceScopeResolver.php
@@ -0,0 +1,34 @@
+ (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
new file mode 100644
index 00000000..ceb5b33c
--- /dev/null
+++ b/src/Resolver/SequenceScopeResolverInterface.php
@@ -0,0 +1,24 @@
+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/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/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/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/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/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/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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %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));
+ }
+}
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/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': ~
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],
]);
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')));
+ }
+}