Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/var/
/vendor/
/node_modules/
/composer.lock
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG-2.0.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# CHANGELOG

### v2.0.3 (2025-10-21)

- [#373](https://github.com/Sylius/InvoicingPlugin/pull/373) Add configurable invoice sequence scope (`monthly`/`annually`/`global`)
via SYLIUS_INVOICING_SEQUENCE_SCOPE ENV ([@tomkalon](https://github.com/tomkalon))

### v2.0.2 (2025-07-03)

- [#373](https://github.com/Sylius/InvoicingPlugin/pull/373) Add sylius/test-application ([@Wojdylak](https://github.com/Wojdylak))
Expand Down
14 changes: 14 additions & 0 deletions UPGRADE-2.0.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# UPGRADE FROM 2.0 TO 2.1

## Changes

1. Added support for configurable invoice sequence scoping via the SYLIUS_INVOICING_SEQUENCE_SCOPE environment variable:

- monthly: resets invoice numbering each month
- annually: resets invoice numbering each year
- global or unset (default): uses a single global sequence (as previously)

## Deprecations

1. Not passing the $scope argument (of type InvoiceSequenceScopeEnum) to the constructor of SequentialInvoiceNumberGenerator is deprecated and will be required starting from version 3.0.

# UPGRADE FROM 1.X TO 2.0

1. Support for Sylius 2.0 has been added, it is now the recommended Sylius version to use with InvoicingPlugin.
Expand Down
2 changes: 2 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ imports:
parameters:
sylius_invoicing.invoice_save_path: "%kernel.project_dir%/private/invoices/"
sylius_invoicing.filesystem_adapter.invoice: "sylius_invoicing_invoice"
sylius_invoicing.sequence_scope: '%env(default::SYLIUS_INVOICING_SEQUENCE_SCOPE)%'
env(SYLIUS_INVOICING_SEQUENCE_SCOPE): 'global'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with the (anything else or empty): one global sequence.

Either it's global or empty, but "anything else" could lead to future issues with custom developments.


sylius_invoicing:
pdf_generator:
Expand Down
3 changes: 3 additions & 0 deletions config/doctrine/InvoiceSequence.orm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
</id>
<field name="index" column="idx" type="integer" />
<field name="version" type="integer" version="true" />
<field name="year" type="integer" nullable="true"/>
<field name="month" type="integer"/>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not having a sequence_type and an enum of possibilities? If we want to improve this or change the behavior we would be obliged to add one or more columns.
WDYT?

</mapped-superclass>

</doctrine-mapping>
1 change: 1 addition & 0 deletions config/services/generators.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<argument type="service" id="sylius_invoicing.factory.invoice_sequence" />
<argument type="service" id="sylius_invoicing.manager.invoice_sequence" />
<argument type="service" id="clock" />
<argument key="$scope">%sylius_invoicing.sequence_scope%</argument>
</service>

<service id="sylius_invoicing.generator.invoice_identifier" class="Sylius\InvoicingPlugin\Generator\UuidInvoiceIdentifierGenerator" />
Expand Down
24 changes: 24 additions & 0 deletions src/Entity/InvoiceSequence.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class InvoiceSequence implements InvoiceSequenceInterface

protected ?int $version = 1;

protected ?int $year;

protected ?int $month;

/** @return mixed */
public function getId()
{
Expand All @@ -48,4 +52,24 @@ public function setVersion(?int $version): void
{
$this->version = $version;
}

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;
}
}
8 changes: 8 additions & 0 deletions src/Entity/InvoiceSequenceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,12 @@ interface InvoiceSequenceInterface extends ResourceInterface, VersionedInterface
public function getIndex(): int;

public function incrementIndex(): void;

public function getYear(): ?int;

public function getMonth(): ?int;

public function setYear(?int $year): void;

public function setMonth(?int $month): void;
}
30 changes: 30 additions & 0 deletions src/Enum/InvoiceSequenceScopeEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Sylius\InvoicingPlugin\Enum;

enum InvoiceSequenceScopeEnum: string
{
case GLOBAL = 'global';
case MONTHLY = 'monthly';
case ANNUALLY = 'annually';

public static function fromString(?string $value): self
{
return match ($value) {
'monthly' => self::MONTHLY,
'annually' => self::ANNUALLY,
default => self::GLOBAL,
};
}
}
45 changes: 41 additions & 4 deletions src/Generator/SequentialInvoiceNumberGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Sylius\InvoicingPlugin\Entity\InvoiceSequenceInterface;
use Sylius\InvoicingPlugin\Enum\InvoiceSequenceScopeEnum;
use Symfony\Component\Clock\ClockInterface;

final class SequentialInvoiceNumberGenerator implements InvoiceNumberGenerator
Expand All @@ -29,7 +30,17 @@ public function __construct(
private readonly ClockInterface $clock,
private readonly int $startNumber = 1,
private readonly int $numberLength = 9,
private readonly ?string $scope = null,
) {
if (null === $this->scope) {
trigger_deprecation(
'sylius/invoicing-plugin',
'2.1',
'Not passing the "%s" argument to "%s::__construct()" is deprecated and will be required in version 3.0. Pass a valid scope explicitly (e.g. "monthly", "annually", or "global").',
'scope',
self::class,
);
}
}

public function generate(): string
Expand All @@ -56,15 +67,41 @@ private function generateNumber(int $index): string

private function getSequence(): InvoiceSequenceInterface
{
/** @var InvoiceSequenceInterface $sequence */
$sequence = $this->sequenceRepository->findOneBy([]);

if (null != $sequence) {
$now = $this->clock->now();
$scope = InvoiceSequenceScopeEnum::tryFrom($this->scope ?? '') ?? InvoiceSequenceScopeEnum::GLOBAL;

$criteria = match ($scope) {
InvoiceSequenceScopeEnum::MONTHLY => [
'year' => (int) $now->format('Y'),
'month' => (int) $now->format('m'),
Comment on lines +75 to +76

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get why you have values here.

You wrote "monthly" or "yearly". You didn't specify that we could chose when it happens precisely.
I feel strange about this.

],
InvoiceSequenceScopeEnum::ANNUALLY => [
'year' => (int) $now->format('Y'),
],
InvoiceSequenceScopeEnum::GLOBAL => [
'year' => null,
'month' => null,
],
};

/** @var InvoiceSequenceInterface|null $sequence */
$sequence = $this->sequenceRepository->findOneBy($criteria);

if (null !== $sequence) {
return $sequence;
}

/** @var InvoiceSequenceInterface $sequence */
$sequence = $this->sequenceFactory->createNew();

if (isset($criteria['year'])) {
$sequence->setYear($criteria['year']);
}

if (isset($criteria['month'])) {
$sequence->setMonth($criteria['month']);
}

$this->sequenceManager->persist($sequence);

return $sequence;
Expand Down
35 changes: 35 additions & 0 deletions src/Migrations/Version20251021074051.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Sylius\InvoicingPlugin\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20251021074051 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add year and month columns to sylius_invoicing_plugin_sequence table';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_invoicing_plugin_sequence ADD year INT DEFAULT NULL, ADD month INT NOT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_invoicing_plugin_sequence DROP year, DROP month');
}
}
3 changes: 3 additions & 0 deletions tests/TestApplication/.env
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ WKHTMLTOPDF_PATH=/usr/local/bin/wkhtmltopdf
###< knplabs/knp-snappy-bundle ###

TEST_SYLIUS_INVOICING_PDF_GENERATION_DISABLED=false
TEST_SYLIUS_INVOICING_PDF_GENERATION_DISABLED=false

SYLIUS_INVOICING_SEQUENCE_SCOPE='monthly'
92 changes: 89 additions & 3 deletions tests/Unit/Generator/SequentialInvoiceNumberGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ public function it_generates_invoice_number(): void
$dateTime = new \DateTimeImmutable('now');
$this->clock->method('now')->willReturn($dateTime);

$this->sequenceRepository->method('findOneBy')->with([])->willReturn($sequence);
$this->sequenceRepository
->method('findOneBy')
->with(['year' => null, 'month' => null])
->willReturn($sequence);

$sequence->method('getVersion')->willReturn(1);
$sequence->method('getIndex')->willReturn(0);
Expand All @@ -96,7 +99,10 @@ public function it_generates_invoice_number_when_sequence_is_null(): void
$dateTime = new \DateTimeImmutable('now');
$this->clock->method('now')->willReturn($dateTime);

$this->sequenceRepository->method('findOneBy')->with([])->willReturn(null);
$this->sequenceRepository
->method('findOneBy')
->with(['year' => null, 'month' => null])
->willReturn(null);

$this->sequenceFactory->method('createNew')->willReturn($sequence);

Expand All @@ -119,6 +125,86 @@ public function it_generates_invoice_number_when_sequence_is_null(): void

$result = $this->generator->generate();

$this->assertSame($dateTime->format('Y/m') . '/000000001', $result);
self::assertSame($dateTime->format('Y/m') . '/000000001', $result);
}

#[Test]
public function it_generates_invoice_number_with_monthly_scope(): void
{
$sequence = $this->createMock(InvoiceSequenceInterface::class);

$dateTime = new \DateTimeImmutable('2025-10-15');
$this->clock->method('now')->willReturn($dateTime);

$generator = new SequentialInvoiceNumberGenerator(
$this->sequenceRepository,
$this->sequenceFactory,
$this->sequenceManager,
$this->clock,
1,
9,
'monthly'
);

$this->sequenceRepository
->method('findOneBy')
->with(['year' => 2025, 'month' => 10])
->willReturn($sequence);

$sequence->method('getVersion')->willReturn(1);
$sequence->method('getIndex')->willReturn(0);

$this->sequenceManager
->expects(self::once())
->method('lock')
->with($sequence, LockMode::OPTIMISTIC, 1);

$sequence
->expects(self::once())
->method('incrementIndex');

$result = $generator->generate();

self::assertSame('2025/10/000000001', $result);
}

#[Test]
public function it_generates_invoice_number_with_annually_scope(): void
{
$sequence = $this->createMock(InvoiceSequenceInterface::class);

$dateTime = new \DateTimeImmutable('2025-11-15');
$this->clock->method('now')->willReturn($dateTime);

$generator = new SequentialInvoiceNumberGenerator(
$this->sequenceRepository,
$this->sequenceFactory,
$this->sequenceManager,
$this->clock,
1,
9,
'annually'
);

$this->sequenceRepository
->method('findOneBy')
->with(['year' => 2025])
->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);
}
}