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