Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `SubmitEInvoiceProcessor` orchestrating queueing, submission and result handling.
- Order-based e-invoice number generator (`EINV/<year>/<order number>`).
- Admin grid and menu entry for browsing e-invoices.
- Automatic e-invoice creation after payment completion, enabled per channel code, with
one-invoice-per-order idempotency.
- Docker + Make based development environment, PHPStan/ECS/Rector/PHPUnit/Behat gates,
GitHub Actions CI, architecture decision log, `ai/` docs, workspace bootstrap script.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ plugins that implement the submission port:
`new -> queued -> submitted -> accepted | rejected | failed` (with `requeue`)
- `EInvoiceSubmitterInterface` port for external systems, `NullSubmitter` default
- `SubmitEInvoiceProcessor` orchestrating transition + submission + result handling
- Automatic e-invoice creation after payment completion, configurable per channel

## Requirements

Expand Down Expand Up @@ -65,7 +66,21 @@ composer require madcoders/sylius-einvoicing-plugin

## Usage

Create and submit an e-invoice for an order:
Enable automatic e-invoice creation for selected channel codes:

```yaml
# config/packages/madcoders_sylius_einvoicing.yaml
madcoders_sylius_einvoicing:
enabled_channels:
- WEB_US
- WEB_PL
```

Automatic creation is disabled by default. When a payment reaches the `completed` state,
the plugin creates and processes one e-invoice for its order. Repeated payment events do
not create duplicates.

You can also create and submit an e-invoice explicitly:

```php
use Madcoders\SyliusEinvoicingPlugin\Factory\EInvoiceFactoryInterface;
Expand Down
5 changes: 5 additions & 0 deletions ai/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Sylius app (or test application)
├── EInvoice resource (entity + XML mapping + sylius_resource registration)
├── madcoders_einvoicing state machine (Symfony Workflow)
├── EInvoiceFactory -> EInvoiceNumberGenerator
├── Payment completed listener (enabled per channel) -> create + process once
├── SubmitEInvoiceProcessor
│ └── EInvoiceSubmitterInterface <- THE PORT (ADR-0002)
│ └── NullSubmitter (default; adapters override the alias)
Expand All @@ -28,6 +29,9 @@ Sylius app (or test application)
`new -> queued -> submitted -> accepted|rejected`, `* -> failed`,
`rejected|failed -> queued` (requeue).
- Workflow config: `config/config.yaml` under `framework.workflows.madcoders_einvoicing`.
- `CreateEInvoiceOnPaymentCompletedSubscriber` listens to the Sylius payment workflow and
creates one e-invoice per order when its channel code is listed in `enabled_channels`.
A unique database constraint on `order_id` backs the repository idempotency check.

## Services (config/services.xml, ADR-0005)

Expand All @@ -37,6 +41,7 @@ Sylius app (or test application)
| `madcoders_sylius_einvoicing.custom_factory.e_invoice` | `EInvoiceFactory` | decorates the resource factory; `createForOrder()` |
| `madcoders_sylius_einvoicing.submitter` | `NullSubmitter` | the port's default; adapters re-alias this id |
| `madcoders_sylius_einvoicing.processor.submit_e_invoice` | `SubmitEInvoiceProcessor` | lifecycle orchestration |
| `madcoders_sylius_einvoicing.subscriber.create_e_invoice_on_payment_completed` | `CreateEInvoiceOnPaymentCompletedSubscriber` | automatic creation after payment |
| `madcoders_sylius_einvoicing.listener.admin_menu` | `AdminMenuListener` | admin menu entry under "sales" |

Each service has an interface-FQCN alias for autowired consumption.
Expand Down
6 changes: 3 additions & 3 deletions ai/tasks/03-order-integration.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 03 - Automatic invoice creation on order payment

- **Status:** Planned
- **Status:** Done (2026-08-02)

Listen to the Sylius order/payment lifecycle (payment `completed`) and create + process
an e-invoice automatically, guarded by a per-channel enable flag. Includes: channel
configuration resource or settings, event subscriber, idempotency (one invoice per
order unless requeued), Behat coverage for the automatic flow.
settings (`enabled_channels`), workflow event listener, database-backed idempotency (one
invoice per order), unit tests and Behat coverage for the automatic flow.
2 changes: 1 addition & 1 deletion config/doctrine/model/EInvoice.orm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<field name="errorMessage" column="error_message" type="text" nullable="true" />

<many-to-one field="order" target-entity="Sylius\Component\Core\Model\OrderInterface">
<join-column name="order_id" referenced-column-name="id" nullable="false" on-delete="CASCADE" />
<join-column name="order_id" referenced-column-name="id" nullable="false" unique="true" on-delete="CASCADE" />
</many-to-one>

<many-to-one field="channel" target-entity="Sylius\Component\Core\Model\ChannelInterface">
Expand Down
10 changes: 10 additions & 0 deletions config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
<service id="Madcoders\SyliusEinvoicingPlugin\Processor\SubmitEInvoiceProcessorInterface"
alias="madcoders_sylius_einvoicing.processor.submit_e_invoice" />

<service id="madcoders_sylius_einvoicing.subscriber.create_e_invoice_on_payment_completed"
class="Madcoders\SyliusEinvoicingPlugin\EventSubscriber\CreateEInvoiceOnPaymentCompletedSubscriber">
<argument type="service" id="madcoders_sylius_einvoicing.repository.e_invoice" />
<argument type="service" id="madcoders_sylius_einvoicing.factory.e_invoice" />
<argument type="service" id="madcoders_sylius_einvoicing.processor.submit_e_invoice" />
<argument type="service" id="doctrine.orm.entity_manager" />
<argument>%madcoders_sylius_einvoicing.enabled_channels%</argument>
<tag name="kernel.event_subscriber" />
</service>

<service id="madcoders_sylius_einvoicing.listener.admin_menu"
class="Madcoders\SyliusEinvoicingPlugin\Menu\AdminMenuListener">
<tag name="kernel.event_listener" event="sylius.menu.admin.main" method="addAdminMenuItems" />
Expand Down
19 changes: 19 additions & 0 deletions features/automatic_e_invoice_creation.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@automatic_e_invoice_creation
Feature: Automatic e-invoice creation after payment
In order to submit e-invoices without manual intervention
As a store owner
I want an e-invoice to be generated when an order payment is completed

Background:
Given the store operates on a single channel in "United States"
And the store ships everywhere for Free
And the store allows paying with "Cash on Delivery"
And the store has a product "PHP T-Shirt"
And there is a customer "john@example.com" that placed an order "#00000001"
And the customer bought 5 "PHP T-Shirt" products
And the customer "John Doe" addressed it to "Seaside Fwy", "90802" "Los Angeles" in the "United States" with identical billing address
And the customer chose "Free" shipping method with "Cash on Delivery" payment

Scenario: Generating an e-invoice when payment is completed in an enabled channel
When this order is already paid
Then an e-invoice should be generated for order "#00000001"
12 changes: 12 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Madcoders\SyliusEinvoicingPlugin\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

Expand All @@ -16,6 +17,17 @@ public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('madcoders_sylius_einvoicing');
$rootNode = $treeBuilder->getRootNode();
\assert($rootNode instanceof ArrayNodeDefinition);

$rootNode
->children()
->arrayNode('enabled_channels')
->info('Channel codes for which e-invoices are created automatically after payment completion.')
->scalarPrototype()->end()
->defaultValue([])
->end()
->end()
;

return $treeBuilder;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ final class MadcodersSyliusEinvoicingExtension extends AbstractResourceExtension
/** @psalm-suppress UnusedVariable */
public function load(array $configs, ContainerBuilder $container): void
{
$config = $this->processConfiguration(new Configuration(), $configs);
$container->setParameter('madcoders_sylius_einvoicing.enabled_channels', $config['enabled_channels']);

$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));

$loader->load('services.xml');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Madcoders\SyliusEinvoicingPlugin\EventSubscriber;

use Doctrine\Persistence\ObjectManager;
use Madcoders\SyliusEinvoicingPlugin\Entity\EInvoiceInterface;
use Madcoders\SyliusEinvoicingPlugin\Factory\EInvoiceFactoryInterface;
use Madcoders\SyliusEinvoicingPlugin\Processor\SubmitEInvoiceProcessorInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Payment\PaymentTransitions;
use Sylius\Resource\Doctrine\Persistence\RepositoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\CompletedEvent;
use Webmozart\Assert\Assert;

final readonly class CreateEInvoiceOnPaymentCompletedSubscriber implements EventSubscriberInterface
{
/**
* @param RepositoryInterface<EInvoiceInterface> $eInvoiceRepository
* @param list<string> $enabledChannelCodes
*/
public function __construct(
private RepositoryInterface $eInvoiceRepository,
private EInvoiceFactoryInterface $eInvoiceFactory,
private SubmitEInvoiceProcessorInterface $submitEInvoiceProcessor,
private ObjectManager $eInvoiceManager,
private array $enabledChannelCodes,
) {
}

public static function getSubscribedEvents(): array
{
return [
sprintf(
'workflow.%s.completed.%s',
PaymentTransitions::GRAPH,
PaymentTransitions::TRANSITION_COMPLETE,
) => ['__invoke', 50],
];
}

public function __invoke(CompletedEvent $event): void
{
$payment = $event->getSubject();
Assert::isInstanceOf($payment, PaymentInterface::class);

$order = $payment->getOrder();
Assert::isInstanceOf($order, OrderInterface::class);

if (!$this->isEnabledFor($order->getChannel())) {
return;
}

if (null !== $this->eInvoiceRepository->findOneBy(['order' => $order])) {
return;
}

$eInvoice = $this->eInvoiceFactory->createForOrder($order);
$this->eInvoiceRepository->add($eInvoice);
$this->submitEInvoiceProcessor->process($eInvoice);
$this->eInvoiceManager->flush();
}

private function isEnabledFor(?ChannelInterface $channel): bool
{
if (null === $channel) {
return false;
}

$channelCode = $channel->getCode();

return null !== $channelCode && in_array($channelCode, $this->enabledChannelCodes, true);
}
}
26 changes: 26 additions & 0 deletions src/Migrations/Version20260802220000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20260802220000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ensure that an order has at most one e-invoice';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE UNIQUE INDEX UNIQ_9BCF3CA58D9F6D38 ON madcoders_einvoicing_e_invoice (order_id)');
}

public function down(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_9BCF3CA58D9F6D38 ON madcoders_einvoicing_e_invoice');
}
}
31 changes: 31 additions & 0 deletions tests/Behat/Context/Domain/AutomaticEInvoiceCreationContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Tests\Madcoders\SyliusEinvoicingPlugin\Behat\Context\Domain;

use Behat\Behat\Context\Context;
use Madcoders\SyliusEinvoicingPlugin\Entity\EInvoiceInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Resource\Doctrine\Persistence\RepositoryInterface;
use Webmozart\Assert\Assert;

final readonly class AutomaticEInvoiceCreationContext implements Context
{
/** @param RepositoryInterface<EInvoiceInterface> $eInvoiceRepository */
public function __construct(private RepositoryInterface $eInvoiceRepository)
{
}

/**
* @Then an e-invoice should be generated for order :order
*/
public function anEInvoiceShouldBeGeneratedForOrder(OrderInterface $order): void
{
$eInvoices = $this->eInvoiceRepository->findBy(['order' => $order]);

Assert::count($eInvoices, 1, 'Expected exactly one e-invoice for the paid order.');
Assert::same($eInvoices[0]->getState(), EInvoiceInterface::STATE_ACCEPTED);
Assert::notNull($eInvoices[0]->getSubmittedAt());
}
}
5 changes: 5 additions & 0 deletions tests/Behat/Resources/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,10 @@
class="Tests\Madcoders\SyliusEinvoicingPlugin\Behat\Context\Ui\Admin\BrowsingEInvoicesContext">
<argument type="service" id="madcoders_sylius_einvoicing.behat.page.admin.e_invoice.index" />
</service>

<service id="madcoders_sylius_einvoicing.behat.context.domain.automatic_e_invoice_creation"
class="Tests\Madcoders\SyliusEinvoicingPlugin\Behat\Context\Domain\AutomaticEInvoiceCreationContext">
<argument type="service" id="madcoders_sylius_einvoicing.repository.e_invoice" />
</service>
</services>
</container>
35 changes: 35 additions & 0 deletions tests/Behat/Resources/suites.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,38 @@ default:

filters:
tags: "@managing_e_invoices && @ui"

madcoders_einvoicing_domain:
contexts:
- sylius.behat.context.hook.doctrine_orm

- 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.locale
- 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.shared_storage
- sylius.behat.context.transform.shipping_method
- sylius.behat.context.transform.zone

- sylius.behat.context.setup.channel
- sylius.behat.context.setup.currency
- sylius.behat.context.setup.customer
- sylius.behat.context.setup.geographical
- sylius.behat.context.setup.locale
- sylius.behat.context.setup.order
- sylius.behat.context.setup.payment
- sylius.behat.context.setup.product
- sylius.behat.context.setup.shipping
- sylius.behat.context.setup.zone

- madcoders_sylius_einvoicing.behat.context.domain.automatic_e_invoice_creation

filters:
tags: "@automatic_e_invoice_creation"
4 changes: 4 additions & 0 deletions tests/TestApplication/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ imports:
- { resource: "@MadcodersSyliusEinvoicingPlugin/config/config.yaml" }
- { resource: "services_test.php" }

madcoders_sylius_einvoicing:
enabled_channels:
- WEB-US

twig:
paths:
'%kernel.project_dir%/../../../tests/TestApplication/templates': ~
Expand Down
Loading
Loading