diff --git a/CHANGELOG.md b/CHANGELOG.md index a42448c..d6b22eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//`). - 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. diff --git a/README.md b/README.md index b38c678..f84fc0a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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; diff --git a/ai/architecture.md b/ai/architecture.md index 9ec0f0c..0ec89d2 100644 --- a/ai/architecture.md +++ b/ai/architecture.md @@ -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) @@ -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) @@ -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. diff --git a/ai/tasks/03-order-integration.md b/ai/tasks/03-order-integration.md index 11810c5..be55a73 100644 --- a/ai/tasks/03-order-integration.md +++ b/ai/tasks/03-order-integration.md @@ -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. diff --git a/config/doctrine/model/EInvoice.orm.xml b/config/doctrine/model/EInvoice.orm.xml index de4dcd3..3a9e8d0 100644 --- a/config/doctrine/model/EInvoice.orm.xml +++ b/config/doctrine/model/EInvoice.orm.xml @@ -18,7 +18,7 @@ - + diff --git a/config/services.xml b/config/services.xml index 6c61faf..b5c03aa 100644 --- a/config/services.xml +++ b/config/services.xml @@ -40,6 +40,16 @@ + + + + + + %madcoders_sylius_einvoicing.enabled_channels% + + + diff --git a/features/automatic_e_invoice_creation.feature b/features/automatic_e_invoice_creation.feature new file mode 100644 index 0000000..98d28d6 --- /dev/null +++ b/features/automatic_e_invoice_creation.feature @@ -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" diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index a7a3ac5..5b6a125 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -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; @@ -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; } diff --git a/src/DependencyInjection/MadcodersSyliusEinvoicingExtension.php b/src/DependencyInjection/MadcodersSyliusEinvoicingExtension.php index 22d5bba..7dca879 100644 --- a/src/DependencyInjection/MadcodersSyliusEinvoicingExtension.php +++ b/src/DependencyInjection/MadcodersSyliusEinvoicingExtension.php @@ -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'); diff --git a/src/EventSubscriber/CreateEInvoiceOnPaymentCompletedSubscriber.php b/src/EventSubscriber/CreateEInvoiceOnPaymentCompletedSubscriber.php new file mode 100644 index 0000000..566858a --- /dev/null +++ b/src/EventSubscriber/CreateEInvoiceOnPaymentCompletedSubscriber.php @@ -0,0 +1,78 @@ + $eInvoiceRepository + * @param list $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); + } +} diff --git a/src/Migrations/Version20260802220000.php b/src/Migrations/Version20260802220000.php new file mode 100644 index 0000000..0ae0d6e --- /dev/null +++ b/src/Migrations/Version20260802220000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/tests/Behat/Context/Domain/AutomaticEInvoiceCreationContext.php b/tests/Behat/Context/Domain/AutomaticEInvoiceCreationContext.php new file mode 100644 index 0000000..2f93110 --- /dev/null +++ b/tests/Behat/Context/Domain/AutomaticEInvoiceCreationContext.php @@ -0,0 +1,31 @@ + $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()); + } +} diff --git a/tests/Behat/Resources/services.xml b/tests/Behat/Resources/services.xml index 97d059e..400b1fa 100644 --- a/tests/Behat/Resources/services.xml +++ b/tests/Behat/Resources/services.xml @@ -15,5 +15,10 @@ class="Tests\Madcoders\SyliusEinvoicingPlugin\Behat\Context\Ui\Admin\BrowsingEInvoicesContext"> + + + + diff --git a/tests/Behat/Resources/suites.yml b/tests/Behat/Resources/suites.yml index 8724e93..e6bf3c6 100644 --- a/tests/Behat/Resources/suites.yml +++ b/tests/Behat/Resources/suites.yml @@ -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" diff --git a/tests/TestApplication/config/config.yaml b/tests/TestApplication/config/config.yaml index 2d7cffd..a8235bb 100644 --- a/tests/TestApplication/config/config.yaml +++ b/tests/TestApplication/config/config.yaml @@ -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': ~ diff --git a/tests/Unit/EventSubscriber/CreateEInvoiceOnPaymentCompletedSubscriberTest.php b/tests/Unit/EventSubscriber/CreateEInvoiceOnPaymentCompletedSubscriberTest.php new file mode 100644 index 0000000..13d5307 --- /dev/null +++ b/tests/Unit/EventSubscriber/CreateEInvoiceOnPaymentCompletedSubscriberTest.php @@ -0,0 +1,121 @@ +eInvoiceRepository = $this->createMock(RepositoryInterface::class); + $this->eInvoiceFactory = $this->createMock(EInvoiceFactoryInterface::class); + $this->submitEInvoiceProcessor = $this->createMock(SubmitEInvoiceProcessorInterface::class); + $this->eInvoiceManager = $this->createMock(ObjectManager::class); + } + + public function testItSubscribesToPaymentCompletion(): void + { + self::assertSame( + ['__invoke', 50], + CreateEInvoiceOnPaymentCompletedSubscriber::getSubscribedEvents()[sprintf( + 'workflow.%s.completed.%s', + PaymentTransitions::GRAPH, + PaymentTransitions::TRANSITION_COMPLETE, + )], + ); + } + + public function testItCreatesAndProcessesAnEInvoiceForAnEnabledChannel(): void + { + $order = $this->orderInChannel('WEB'); + $eInvoice = $this->createMock(EInvoiceInterface::class); + + $this->eInvoiceRepository->expects(self::once())->method('findOneBy')->with(['order' => $order])->willReturn(null); + $this->eInvoiceFactory->expects(self::once())->method('createForOrder')->with($order)->willReturn($eInvoice); + $this->eInvoiceRepository->expects(self::once())->method('add')->with($eInvoice); + $this->submitEInvoiceProcessor->expects(self::once())->method('process')->with($eInvoice); + $this->eInvoiceManager->expects(self::once())->method('flush'); + + $this->subscriber(['WEB'])($this->completedEventFor($order)); + } + + public function testItDoesNothingForADisabledChannel(): void + { + $order = $this->orderInChannel('MOBILE'); + + $this->eInvoiceRepository->expects(self::never())->method('findOneBy'); + $this->eInvoiceFactory->expects(self::never())->method('createForOrder'); + $this->submitEInvoiceProcessor->expects(self::never())->method('process'); + $this->eInvoiceManager->expects(self::never())->method('flush'); + + $this->subscriber(['WEB'])($this->completedEventFor($order)); + } + + public function testItDoesNotCreateASecondEInvoiceForTheSameOrder(): void + { + $order = $this->orderInChannel('WEB'); + $existingEInvoice = $this->createMock(EInvoiceInterface::class); + + $this->eInvoiceRepository->expects(self::once())->method('findOneBy')->with(['order' => $order])->willReturn($existingEInvoice); + $this->eInvoiceFactory->expects(self::never())->method('createForOrder'); + $this->submitEInvoiceProcessor->expects(self::never())->method('process'); + $this->eInvoiceManager->expects(self::never())->method('flush'); + + $this->subscriber(['WEB'])($this->completedEventFor($order)); + } + + /** @param list $enabledChannelCodes */ + private function subscriber(array $enabledChannelCodes): CreateEInvoiceOnPaymentCompletedSubscriber + { + return new CreateEInvoiceOnPaymentCompletedSubscriber( + $this->eInvoiceRepository, + $this->eInvoiceFactory, + $this->submitEInvoiceProcessor, + $this->eInvoiceManager, + $enabledChannelCodes, + ); + } + + private function orderInChannel(string $channelCode): OrderInterface + { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + + $order = $this->createMock(OrderInterface::class); + $order->method('getChannel')->willReturn($channel); + + return $order; + } + + private function completedEventFor(OrderInterface $order): CompletedEvent + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getOrder')->willReturn($order); + + return new CompletedEvent($payment, new Marking()); + } +}