From 03c8e8f05a25f8f7ccad7c62dc7b876e7cf5d4e0 Mon Sep 17 00:00:00 2001 From: alejandro-yakovlev Date: Wed, 21 Feb 2024 21:31:37 +0500 Subject: [PATCH] saga --- Makefile | 2 +- README.md | 5 +- composer.json | 1 + composer.lock | 90 ++++++++++++++++- config/packages/doctrine.yaml | 12 +++ config/packages/messenger.yaml | 59 +++++++++-- config/packages/workflow.yaml | 48 +++++++++ deptrac-modules.yaml | 12 +++ docker/php-fpm/Dockerfile | 3 + docker/php-fpm/supervisord.conf | 14 ++- .../TestCompletePaymentConsoleCommand.php | 30 ++++++ src/Console/TestConsoleCommand.php | 21 ++-- .../InvoiceCancelledExternalEvent.php | 22 +++++ .../InvoiceCancelledExternalEventHandler.php | 20 ++++ .../OrderCreatedExternalEvent.php | 2 +- .../OrderCreatedExternalEventHandler.php | 21 ++++ .../Command/AddProduct/AddProductCommand.php | 14 +++ .../AddProduct/AddProductCommandHandler.php | 20 ++++ .../ReleaseReservedProductsCommand.php | 14 +++ .../ReleaseReservedProductsCommandHandler.php | 20 ++++ .../ReserveProductsCommand.php | 17 ++++ .../ReserveProductsCommandHandler.php | 20 ++++ .../UseCase/InventoryUseCaseInteractor.php | 35 +++++++ .../Domain/Aggregate/AggregateRoot.php | 36 +++++++ .../Domain/Aggregate/DomainEventInterface.php | 10 ++ .../Domain/Aggregate/Product/Product.php | 46 +++++++++ .../Product/ProductRepositoryInterface.php | 24 +++++ .../Aggregate/Product/ProductReservation.php | 44 +++++++++ .../ProductReservationRejectedDomainEvent.php | 23 +++++ .../ProductReservationRepositoryInterface.php | 20 ++++ ...ProductsReservationReleasedDomainEvent.php | 23 +++++ .../Product/ProductsReservedDomainEvent.php | 23 +++++ .../Domain/Factory/ProductFactory.php | 15 +++ .../Factory/ProductReservationFactory.php | 15 +++ .../Service/DomainEventPublisherInterface.php | 12 +++ .../Domain/Service/InventoryService.php | 79 +++++++++++++++ .../Domain/Service/ProductService.php | 23 +++++ .../Event/DomainEventProducer.php | 39 ++++++++ .../Event/DomainEventPublisher.php | 21 ++++ .../Infrastructure/Event/EventEnvelope.php | 46 +++++++++ .../Event/EventEnvelopeHandler.php | 34 +++++++ .../Event/EventEnvelopeSerializer.php | 42 ++++++++ .../Event/Outbox/OutboxMessage.php | 30 ++++++ .../Event/Outbox/OutboxMessageProducer.php | 23 +++++ .../Event/Outbox/OutboxMessageRelay.php | 31 ++++++ .../ORM/Aggregate/Product.Product.orm.xml | 15 +++ .../Product.ProductReservation.orm.xml | 20 ++++ .../Repository/ProductRepository.php | 43 ++++++++ .../ProductReservationRepository.php | 41 ++++++++ .../OrderPaid/OrderPaidEventListener.php | 21 ++++ .../InvoiceCancelledExternalEvent.php | 22 +++++ .../InvoiceCancelledExternalEventHandler.php | 20 ++++ .../InvoicePaid/InvoicePaidExternalEvent.php | 22 +++++ .../InvoicePaidExternalEventHandler.php | 20 ++++ ...roductReservationRejectedExternalEvent.php | 15 +++ ...eservationRejectedExternalEventHandler.php | 20 ++++ .../Service/Material/MaterialApiInterface.php | 10 -- .../Service/Product/ProductApiInterface.php | 10 ++ .../ProductDTO.php} | 4 +- .../Service/Product/ProductService.php | 17 ++++ .../CreateMaterialPurchaseOrderCommand.php | 14 --- ...ateMaterialPurchaseOrderCommandHandler.php | 33 ------- .../CreateOrder/CreateOrderCommand.php | 14 +++ .../CreateOrder/CreateOrderCommandHandler.php | 32 ++++++ .../CreateOrder/CreateOrderCommandResult.php | 12 +++ .../Application/UseCase/OrdersUseCase.php | 11 ++- src/Orders/Domain/Aggregate/Order/Order.php | 45 ++++++--- .../Order/OrderCancelledDomainEvent.php | 25 +++++ .../Order/OrderCompletedDomainEvent.php | 25 +++++ .../Aggregate/Order/OrderCreatedEvent.php | 8 +- .../Aggregate/Order/OrderPaidDomainEvent.php | 25 +++++ .../Order/OrderRepositoryInterface.php | 2 + src/Orders/Domain/Factory/OrderFactory.php | 7 +- .../Service/DomainEventPublisherInterface.php | 12 +++ src/Orders/Domain/Service/OrderService.php | 71 +++++++++++++ .../Adapter/Materials/MaterialsAdapter.php | 26 ----- .../Adapter/Products/ProductsAdapter.php | 17 ++++ .../Event/DomainEventPublisher.php | 21 ++++ .../Event/EventEnvelopeHandler.php | 8 ++ .../Repository/OrderRepository.php | 5 + .../Event/PaymentWasPaidEventHandler.php | 25 +++++ .../OrderCreatedExternalEvent.php | 45 +++++++++ .../OrderCreatedExternalEventHandler.php | 32 ++++++ .../ProductsReservedExternalEvent.php | 25 +++++ .../ProductsReservedExternalEventHandler.php | 24 +++++ .../OrderCreatedExternalEventHandler.php | 34 ------- .../PaymentGatewayInterface.php | 14 +++ .../Service/PaymentGateway/PaymentResult.php | 16 +++ .../Service/PaymentGateway/Status.php | 12 +++ .../Service/PaymentGateway/StatusResult.php | 29 ++++++ .../CompletePaymentCommand.php | 14 +++ .../CompletePaymentCommandHandler.php | 34 +++++++ .../CreateInvoice/CreateInvoiceCommand.php | 24 +++++ .../CreateInvoiceCommandHandler.php | 27 +++++ .../Command/PayInvoice/PayInvoiceCommand.php | 14 +++ .../PayInvoice/PayInvoiceCommandHandler.php | 54 ++++++++++ .../PayInvoice/PayInvoiceCommandResult.php | 18 ++++ .../UseCase/PaymentsUseCaseInteractor.php | 46 +++++++++ .../Domain/Aggregate/Invoice/Invoice.php | 63 +++++++++++- .../Invoice/InvoiceCancelledDomainEvent.php | 30 ++++++ .../Invoice/InvoicePaidDomainEvent.php | 14 +++ .../Invoice/InvoiceRepositoryInterface.php | 4 + .../Domain/Aggregate/Invoice/Status.php | 5 + .../Domain/Aggregate/Payment/Payment.php | 95 ++++++++++++++++-- .../{Gateway.php => PaymentMethod.php} | 5 +- .../Payment/PaymentRepositoryInterface.php | 14 +++ .../Payment/PaymentWasPaidDomainEvent.php | 20 ++++ .../Domain/Aggregate/Payment/Status.php | 8 +- .../Domain/Factory/InvoiceFactory.php | 5 +- .../Domain/Factory/PaymentFactory.php | 22 +++++ .../Service/DomainEventPublisherInterface.php | 12 +++ .../Domain/Service/InvoiceService.php | 38 +++++++ .../Domain/Service/PaymentService.php | 31 ++++++ .../Event/DomainEventProducer.php | 16 +-- .../Event/DomainEventPublisher.php | 21 ++++ .../Event/EventEnvelopeHandler.php | 26 +++-- .../Event/Outbox/OutboxMessage.php | 30 ++++++ .../Event/Outbox/OutboxMessageProducer.php | 23 +++++ .../Event/Outbox/OutboxMessageRelay.php | 31 ++++++ .../PublishAggregateEventsOnFlushListener.php | 5 +- .../ORM/Aggregate/Invoice.Invoice.orm.xml | 1 + .../ORM/Aggregate/Payment.Payment.orm.xml | 26 +++++ .../ORM/Type/InvoiceItemsType.php | 7 +- .../Repository/InvoiceRepository.php | 10 ++ .../Repository/PaymentRepository.php | 34 +++++++ .../Service/PaymentDummyGatewayService.php | 44 +++++++++ .../CreateOrder/Adapter/InventoryService.php | 16 +++ src/Saga/CreateOrder/Adapter/OrderService.php | 12 +++ .../CreateOrder/Adapter/PaymentService.php | 17 ++++ .../Entity/CreateOrderSagaEntity.php | 99 +++++++++++++++++++ src/Saga/CreateOrder/Entity/State.php | 31 ++++++ .../InvoiceCancelledExternalEvent.php | 12 +++ .../InvoiceCancelledExternalEventHandler.php | 20 ++++ .../InvoicePaid/InvoicePaidExternalEvent.php | 12 +++ .../InvoicePaidExternalEventHandler.php | 20 ++++ .../OrderCancelledExternalEvent.php | 12 +++ .../OrderCancelledExternalEventListener.php | 20 ++++ .../OrderCompletedExternalEvent.php | 12 +++ .../OrderCompletedExternalEventListener.php | 20 ++++ .../OrderCreatedExternalEvent.php | 16 +++ .../OrderCreatedExternalEventListener.php | 20 ++++ ...roductReservationRejectedExternalEvent.php | 15 +++ ...eservationRejectedExternalEventHandler.php | 20 ++++ .../ProductsReservedExternalEvent.php | 15 +++ .../ProductsReservedExternalEventHandler.php | 20 ++++ .../Repository/CreateOrderSagaRepository.php | 34 +++++++ src/Saga/CreateOrder/SagaMarkingStore.php | 27 +++++ .../Choreography/CreateOrderChoreography.php | 63 ++++++++++++ .../Orchestrator/CreateOrderOrchestrator.php | 51 ++++++++++ .../Service/Orchestrator/StepInterface.php | 12 +++ .../Orchestrator/Steps/CreateOrderStep.php | 27 +++++ .../Orchestrator/Steps/PayOrderStep.php | 35 +++++++ .../Steps/ReserveProductsStep.php | 35 +++++++ .../CreateOrder/Service/SagaStateService.php | 31 ++++++ src/Saga/Event/EventEnvelope.php | 46 +++++++++ src/Saga/Event/EventEnvelopeHandler.php | 46 +++++++++ src/Saga/Event/EventEnvelopeSerializer.php | 46 +++++++++ src/Shared/Domain/Event/EventType.php | 8 ++ ...17091038.php => Version20240205205103.php} | 67 +++++++++++-- ...19175426.php => Version20240205210058.php} | 10 +- .../Migrations/Version20240218113228.php | 42 ++++++++ .../Migrations/Version20240218194337.php | 35 +++++++ .../Event/EventEnvelopeHandler.php | 20 ++-- symfony.lock | 12 +++ .../ReleaseReservedProductsCommandTest.php | 50 ++++++++++ .../UseCase/ReserveProductsCommandTest.php | 54 ++++++++++ .../Command/CompletePaymentCommandTest.php | 56 +++++++++++ .../Command/CreateInvoiceCommandTest.php | 50 ++++++++++ .../UseCase/Command/PayInvoiceCommandTest.php | 62 ++++++++++++ .../Fixture/Inventory/ProductFixture.php | 34 +++++++ .../Inventory/ProductReservationFixture.php | 47 +++++++++ .../Fixture/Payments/InvoiceFixture.php | 40 ++++++++ .../Fixture/Payments/PaymentFixture.php | 47 +++++++++ tests/Tools/FixtureTools.php | 36 +++++++ 174 files changed, 4294 insertions(+), 239 deletions(-) create mode 100644 config/packages/workflow.yaml create mode 100644 src/Console/TestCompletePaymentConsoleCommand.php create mode 100644 src/Inventory/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEvent.php create mode 100644 src/Inventory/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php rename src/{Payments/Application/ExternalEvents => Inventory/Application/ExternalEvents/OrderCreated}/OrderCreatedExternalEvent.php (91%) create mode 100644 src/Inventory/Application/ExternalEvents/OrderCreated/OrderCreatedExternalEventHandler.php create mode 100644 src/Inventory/Application/UseCase/Command/AddProduct/AddProductCommand.php create mode 100644 src/Inventory/Application/UseCase/Command/AddProduct/AddProductCommandHandler.php create mode 100644 src/Inventory/Application/UseCase/Command/ReleaseReservedProducts/ReleaseReservedProductsCommand.php create mode 100644 src/Inventory/Application/UseCase/Command/ReleaseReservedProducts/ReleaseReservedProductsCommandHandler.php create mode 100644 src/Inventory/Application/UseCase/Command/ReserveProducts/ReserveProductsCommand.php create mode 100644 src/Inventory/Application/UseCase/Command/ReserveProducts/ReserveProductsCommandHandler.php create mode 100644 src/Inventory/Application/UseCase/InventoryUseCaseInteractor.php create mode 100644 src/Inventory/Domain/Aggregate/AggregateRoot.php create mode 100755 src/Inventory/Domain/Aggregate/DomainEventInterface.php create mode 100644 src/Inventory/Domain/Aggregate/Product/Product.php create mode 100644 src/Inventory/Domain/Aggregate/Product/ProductRepositoryInterface.php create mode 100644 src/Inventory/Domain/Aggregate/Product/ProductReservation.php create mode 100644 src/Inventory/Domain/Aggregate/Product/ProductReservationRejectedDomainEvent.php create mode 100644 src/Inventory/Domain/Aggregate/Product/ProductReservationRepositoryInterface.php create mode 100644 src/Inventory/Domain/Aggregate/Product/ProductsReservationReleasedDomainEvent.php create mode 100644 src/Inventory/Domain/Aggregate/Product/ProductsReservedDomainEvent.php create mode 100644 src/Inventory/Domain/Factory/ProductFactory.php create mode 100644 src/Inventory/Domain/Factory/ProductReservationFactory.php create mode 100644 src/Inventory/Domain/Service/DomainEventPublisherInterface.php create mode 100644 src/Inventory/Domain/Service/InventoryService.php create mode 100644 src/Inventory/Domain/Service/ProductService.php create mode 100644 src/Inventory/Infrastructure/Event/DomainEventProducer.php create mode 100644 src/Inventory/Infrastructure/Event/DomainEventPublisher.php create mode 100644 src/Inventory/Infrastructure/Event/EventEnvelope.php create mode 100644 src/Inventory/Infrastructure/Event/EventEnvelopeHandler.php create mode 100644 src/Inventory/Infrastructure/Event/EventEnvelopeSerializer.php create mode 100644 src/Inventory/Infrastructure/Event/Outbox/OutboxMessage.php create mode 100644 src/Inventory/Infrastructure/Event/Outbox/OutboxMessageProducer.php create mode 100644 src/Inventory/Infrastructure/Event/Outbox/OutboxMessageRelay.php create mode 100644 src/Inventory/Infrastructure/ORM/Aggregate/Product.Product.orm.xml create mode 100644 src/Inventory/Infrastructure/ORM/Aggregate/Product.ProductReservation.orm.xml create mode 100644 src/Inventory/Infrastructure/Repository/ProductRepository.php create mode 100644 src/Inventory/Infrastructure/Repository/ProductReservationRepository.php create mode 100644 src/Orders/Application/Events/OrderPaid/OrderPaidEventListener.php create mode 100644 src/Orders/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEvent.php create mode 100644 src/Orders/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php create mode 100644 src/Orders/Application/ExternalEvents/InvoicePaid/InvoicePaidExternalEvent.php create mode 100644 src/Orders/Application/ExternalEvents/InvoicePaid/InvoicePaidExternalEventHandler.php create mode 100644 src/Orders/Application/ExternalEvents/ProductReservationRejected/ProductReservationRejectedExternalEvent.php create mode 100644 src/Orders/Application/ExternalEvents/ProductReservationRejected/ProductReservationRejectedExternalEventHandler.php delete mode 100644 src/Orders/Application/Service/Material/MaterialApiInterface.php create mode 100644 src/Orders/Application/Service/Product/ProductApiInterface.php rename src/Orders/Application/Service/{Material/MaterialDTO.php => Product/ProductDTO.php} (66%) create mode 100644 src/Orders/Application/Service/Product/ProductService.php delete mode 100644 src/Orders/Application/UseCase/Command/CreateMaterialPurchaseOrder/CreateMaterialPurchaseOrderCommand.php delete mode 100644 src/Orders/Application/UseCase/Command/CreateMaterialPurchaseOrder/CreateMaterialPurchaseOrderCommandHandler.php create mode 100644 src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommand.php create mode 100644 src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommandHandler.php create mode 100644 src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommandResult.php create mode 100644 src/Orders/Domain/Aggregate/Order/OrderCancelledDomainEvent.php create mode 100644 src/Orders/Domain/Aggregate/Order/OrderCompletedDomainEvent.php create mode 100644 src/Orders/Domain/Aggregate/Order/OrderPaidDomainEvent.php create mode 100644 src/Orders/Domain/Service/DomainEventPublisherInterface.php create mode 100644 src/Orders/Domain/Service/OrderService.php delete mode 100644 src/Orders/Infrastructure/Adapter/Materials/MaterialsAdapter.php create mode 100644 src/Orders/Infrastructure/Adapter/Products/ProductsAdapter.php create mode 100644 src/Orders/Infrastructure/Event/DomainEventPublisher.php create mode 100644 src/Payments/Application/Event/PaymentWasPaidEventHandler.php create mode 100644 src/Payments/Application/ExternalEvent/OrderCreated/OrderCreatedExternalEvent.php create mode 100644 src/Payments/Application/ExternalEvent/OrderCreated/OrderCreatedExternalEventHandler.php create mode 100644 src/Payments/Application/ExternalEvent/ProductsReserved/ProductsReservedExternalEvent.php create mode 100644 src/Payments/Application/ExternalEvent/ProductsReserved/ProductsReservedExternalEventHandler.php delete mode 100644 src/Payments/Application/ExternalEvents/OrderCreatedExternalEventHandler.php create mode 100644 src/Payments/Application/Service/PaymentGateway/PaymentGatewayInterface.php create mode 100644 src/Payments/Application/Service/PaymentGateway/PaymentResult.php create mode 100644 src/Payments/Application/Service/PaymentGateway/Status.php create mode 100644 src/Payments/Application/Service/PaymentGateway/StatusResult.php create mode 100644 src/Payments/Application/UseCase/Command/CompletePayment/CompletePaymentCommand.php create mode 100644 src/Payments/Application/UseCase/Command/CompletePayment/CompletePaymentCommandHandler.php create mode 100644 src/Payments/Application/UseCase/Command/CreateInvoice/CreateInvoiceCommand.php create mode 100644 src/Payments/Application/UseCase/Command/CreateInvoice/CreateInvoiceCommandHandler.php create mode 100644 src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommand.php create mode 100644 src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommandHandler.php create mode 100644 src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommandResult.php create mode 100644 src/Payments/Application/UseCase/PaymentsUseCaseInteractor.php create mode 100644 src/Payments/Domain/Aggregate/Invoice/InvoiceCancelledDomainEvent.php rename src/Payments/Domain/Aggregate/Payment/{Gateway.php => PaymentMethod.php} (51%) create mode 100644 src/Payments/Domain/Aggregate/Payment/PaymentRepositoryInterface.php create mode 100644 src/Payments/Domain/Aggregate/Payment/PaymentWasPaidDomainEvent.php create mode 100644 src/Payments/Domain/Factory/PaymentFactory.php create mode 100644 src/Payments/Domain/Service/DomainEventPublisherInterface.php create mode 100644 src/Payments/Domain/Service/InvoiceService.php create mode 100644 src/Payments/Domain/Service/PaymentService.php create mode 100644 src/Payments/Infrastructure/Event/DomainEventPublisher.php create mode 100644 src/Payments/Infrastructure/Event/Outbox/OutboxMessage.php create mode 100644 src/Payments/Infrastructure/Event/Outbox/OutboxMessageProducer.php create mode 100644 src/Payments/Infrastructure/Event/Outbox/OutboxMessageRelay.php create mode 100644 src/Payments/Infrastructure/ORM/Aggregate/Payment.Payment.orm.xml create mode 100644 src/Payments/Infrastructure/Repository/PaymentRepository.php create mode 100644 src/Payments/Infrastructure/Service/PaymentDummyGatewayService.php create mode 100644 src/Saga/CreateOrder/Adapter/InventoryService.php create mode 100644 src/Saga/CreateOrder/Adapter/OrderService.php create mode 100644 src/Saga/CreateOrder/Adapter/PaymentService.php create mode 100644 src/Saga/CreateOrder/Entity/CreateOrderSagaEntity.php create mode 100644 src/Saga/CreateOrder/Entity/State.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/InvoiceCancelled/InvoiceCancelledExternalEvent.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/InvoicePaid/InvoicePaidExternalEvent.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/InvoicePaid/InvoicePaidExternalEventHandler.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/OrderCancelled/OrderCancelledExternalEvent.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/OrderCancelled/OrderCancelledExternalEventListener.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/OrderCompleted/OrderCompletedExternalEvent.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/OrderCompleted/OrderCompletedExternalEventListener.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/OrderCreated/OrderCreatedExternalEvent.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/OrderCreated/OrderCreatedExternalEventListener.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/ProductReservationRejected/ProductReservationRejectedExternalEvent.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/ProductReservationRejected/ProductReservationRejectedExternalEventHandler.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/ProductsReserved/ProductsReservedExternalEvent.php create mode 100644 src/Saga/CreateOrder/ExternalEvent/ProductsReserved/ProductsReservedExternalEventHandler.php create mode 100644 src/Saga/CreateOrder/Repository/CreateOrderSagaRepository.php create mode 100644 src/Saga/CreateOrder/SagaMarkingStore.php create mode 100644 src/Saga/CreateOrder/Service/Choreography/CreateOrderChoreography.php create mode 100644 src/Saga/CreateOrder/Service/Orchestrator/CreateOrderOrchestrator.php create mode 100644 src/Saga/CreateOrder/Service/Orchestrator/StepInterface.php create mode 100644 src/Saga/CreateOrder/Service/Orchestrator/Steps/CreateOrderStep.php create mode 100644 src/Saga/CreateOrder/Service/Orchestrator/Steps/PayOrderStep.php create mode 100644 src/Saga/CreateOrder/Service/Orchestrator/Steps/ReserveProductsStep.php create mode 100644 src/Saga/CreateOrder/Service/SagaStateService.php create mode 100644 src/Saga/Event/EventEnvelope.php create mode 100644 src/Saga/Event/EventEnvelopeHandler.php create mode 100644 src/Saga/Event/EventEnvelopeSerializer.php rename src/Shared/Infrastructure/Database/Migrations/{Version20240117091038.php => Version20240205205103.php} (71%) rename src/Shared/Infrastructure/Database/Migrations/{Version20240119175426.php => Version20240205210058.php} (53%) create mode 100644 src/Shared/Infrastructure/Database/Migrations/Version20240218113228.php create mode 100644 src/Shared/Infrastructure/Database/Migrations/Version20240218194337.php create mode 100644 tests/Functional/Inventory/Application/UseCase/ReleaseReservedProductsCommandTest.php create mode 100644 tests/Functional/Inventory/Application/UseCase/ReserveProductsCommandTest.php create mode 100644 tests/Functional/Payments/Application/UseCase/Command/CompletePaymentCommandTest.php create mode 100644 tests/Functional/Payments/Application/UseCase/Command/CreateInvoiceCommandTest.php create mode 100644 tests/Functional/Payments/Application/UseCase/Command/PayInvoiceCommandTest.php create mode 100644 tests/Resource/Fixture/Inventory/ProductFixture.php create mode 100644 tests/Resource/Fixture/Inventory/ProductReservationFixture.php create mode 100644 tests/Resource/Fixture/Payments/InvoiceFixture.php create mode 100644 tests/Resource/Fixture/Payments/PaymentFixture.php diff --git a/Makefile b/Makefile index 413acc2..7fd072c 100755 --- a/Makefile +++ b/Makefile @@ -70,7 +70,7 @@ db_schema_validate: ${DOCKER_COMPOSE} exec -u www-data php-fpm bin/console doctrine:schema:validate db_migration_down: - ${DOCKER_COMPOSE} exec -u www-data php-fpm bin/console doctrine:migrations:execute "App\Shared\Infrastructure\Database\Migrations\Version********" --down --dry-run + ${DOCKER_COMPOSE} exec -u www-data php-fpm bin/console doctrine:migrations:execute "App\Shared\Infrastructure\Database\Migrations\Version20240201164919" --down --dry-run db_drop: docker-compose -f ./docker/docker-compose.yml exec -u www-data php-fpm bin/console doctrine:schema:drop --force diff --git a/README.md b/README.md index 4252aef..f56ce10 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,7 @@ - [X] [Gitlab CI/CD](https://docs.gitlab.com/ee/ci/yaml/) - [X] [GitHub Actions](https://docs.github.com/en/actions/quickstart) -Документация [в разработке](docs/index.md). \ No newline at end of file +Документация [в разработке](docs/index.md). + + +// Добавить слушатели REJECTED и CONFIRMED методов в orders контексте. \ No newline at end of file diff --git a/composer.json b/composer.json index d3cea7b..14489b8 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "symfony/twig-bundle": "^6.4", "symfony/uid": "^6.4", "symfony/validator": "^6.4", + "symfony/workflow": "6.*", "symfony/yaml": "^6.4", "webmozart/assert": "^1.11" }, diff --git a/composer.lock b/composer.lock index ea33d23..0be3453 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "622805308e71b9a6e0be3cc9082b88eb", + "content-hash": "7991d0afbe3e66e37c8956f7af7f03ef", "packages": [ { "name": "behat/transliterator", @@ -6580,6 +6580,94 @@ ], "time": "2023-12-27T08:18:35+00:00" }, + { + "name": "symfony/workflow", + "version": "v6.4.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/workflow.git", + "reference": "3cd13dc5c2c44b94e0fbe8c691c8e2635ffe3c56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/workflow/zipball/3cd13dc5c2c44b94e0fbe8c691c8e2635ffe3c56", + "reference": "3cd13dc5c2c44b94e0fbe8c691c8e2635ffe3c56", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/event-dispatcher": "<5.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/validator": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Workflow\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools for managing a workflow or finite state machine", + "homepage": "https://symfony.com", + "keywords": [ + "petrinet", + "place", + "state", + "statemachine", + "transition", + "workflow" + ], + "support": { + "source": "https://github.com/symfony/workflow/tree/v6.4.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-23T14:51:35+00:00" + }, { "name": "symfony/yaml", "version": "v6.4.0", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index fc4f4ee..ebbc05c 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -55,6 +55,18 @@ doctrine: dir: '%kernel.project_dir%/src/Payments/Infrastructure/ORM/Aggregate' prefix: 'App\Payments\Domain\Aggregate' alias: Payment + Inventory: + is_bundle: false + type: xml + dir: '%kernel.project_dir%/src/Inventory/Infrastructure/ORM/Aggregate' + prefix: 'App\Inventory\Domain\Aggregate' + alias: Inventory + Saga: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/Saga' + prefix: 'App\Saga' + alias: Saga #when@test: # doctrine: diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index f9914ff..e4e7449 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -22,8 +22,34 @@ framework: table_name: 'orders_outbox_message' use_notify: true auto_setup: true - # failed: 'doctrine://default?queue_name=failed' - # sync: 'sync://' + check_delayed_interval: 1000 + inventory_outbox: + dsn: '%env(DOCTRINE_MESSENGER_TRANSPORT_DSN)%' + options: + table_name: 'inventory_outbox_message' + use_notify: true + auto_setup: true + check_delayed_interval: 1000 + payments_outbox: + dsn: '%env(DOCTRINE_MESSENGER_TRANSPORT_DSN)%' + options: + table_name: 'payments_outbox_message' + use_notify: true + auto_setup: true + check_delayed_interval: 1000 + saga: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + serializer: 'App\Saga\Event\EventEnvelopeSerializer' + options: + exchange: + name: 'saga' + type: 'topic' + queues: + saga: + binding_keys: + - 'payments.#' + - 'inventory.#' + - 'orders.#' orders: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' serializer: 'App\Orders\Infrastructure\Event\EventEnvelopeSerializer' @@ -34,11 +60,26 @@ framework: queues: orders: binding_keys: + - 'payments.#' + - 'inventory.#' - 'orders.#' + inventory: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + serializer: 'App\Inventory\Infrastructure\Event\EventEnvelopeSerializer' +# retry_strategy: +# max_retries: 3 +# delay: 1000 +# multiplier: 2 + options: + exchange: + name: 'inventory' + type: 'topic' + queues: + inventory: + binding_keys: - 'payments.#' -# arguments: -# x-max-length: 10000 -# x-overflow: reject-publish + - 'inventory.#' + - 'orders.#' payments: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' serializer: 'App\Payments\Infrastructure\Event\EventEnvelopeSerializer' @@ -50,9 +91,13 @@ framework: payments: binding_keys: - 'payments.#' + - 'inventory.#' - 'orders.#' routing: + 'App\Orders\Infrastructure\Event\EventEnvelope': [orders, payments, inventory, saga] 'App\Orders\Infrastructure\Event\Outbox\OutboxMessage': orders_outbox - 'App\Orders\Infrastructure\Event\EventEnvelope': [orders, payments] - 'App\Payments\Infrastructure\Event\EventEnvelope': [orders, payments] + 'App\Payments\Infrastructure\Event\EventEnvelope': [orders, payments, inventory, saga] + 'App\Payments\Infrastructure\Event\Outbox\OutboxMessage': payments_outbox + 'App\Inventory\Infrastructure\Event\EventEnvelope': [orders, payments, inventory, saga] + 'App\Inventory\Infrastructure\Event\Outbox\OutboxMessage': inventory_outbox diff --git a/config/packages/workflow.yaml b/config/packages/workflow.yaml new file mode 100644 index 0000000..d7d5801 --- /dev/null +++ b/config/packages/workflow.yaml @@ -0,0 +1,48 @@ +framework: + workflows: + create_order: + type: 'state_machine' +# marking_store: +# type: 'method' +# property: 'state' + marking_store: + service: App\Saga\CreateOrder\SagaMarkingStore + supports: + - App\Saga\CreateOrder\Entity\CreateOrderSagaEntity + initial_marking: reservation_pending + places: + reservation_pending: ~ + reservation_rejected: ~ + reservation_confirmed: ~ + + payment_pending: ~ + payment_rejected: ~ + payment_confirmed: ~ + + order_completed: ~ + order_cancelled: ~ + transitions: + confirm_reservation: + from: reservation_pending + to: reservation_confirmed + reject_reservation: + from: reservation_pending + to: reservation_rejected + + pay_order: + from: reservation_confirmed + to: payment_pending + confirm_payment: + from: payment_pending + to: payment_confirmed + reject_payment: + from: payment_pending + to: payment_rejected + + complete: + from: payment_confirmed + to: order_completed + + cancel: + from: [reservation_rejected, payment_rejected] + to: order_cancelled \ No newline at end of file diff --git a/deptrac-modules.yaml b/deptrac-modules.yaml index c273d09..81d33e4 100644 --- a/deptrac-modules.yaml +++ b/deptrac-modules.yaml @@ -33,6 +33,14 @@ parameters: collectors: - type: directory regex: /src/Payments/.* + - name: Inventory + collectors: + - type: directory + regex: /src/Inventory/.* + - name: Saga + collectors: + - type: directory + regex: /src/Saga/.* ruleset: Skills: - Shared @@ -46,3 +54,7 @@ parameters: - Shared Payments: - Shared + Inventory: + - Shared + Saga: + - Shared diff --git a/docker/php-fpm/Dockerfile b/docker/php-fpm/Dockerfile index 580ceea..12febcb 100755 --- a/docker/php-fpm/Dockerfile +++ b/docker/php-fpm/Dockerfile @@ -37,6 +37,9 @@ RUN apk add --no-cache rabbitmq-c-dev \ RUN apk add --no-cache supervisor COPY ./docker/php-fpm/supervisord.conf /etc/supervisord.conf +# Graphviz, provides the dot command (Symfony Workflow component) +RUN apk add --no-cache graphviz ttf-freefont + # Source code RUN chown www-data:www-data /var/www COPY --chown=www-data:www-data ./ /var/www diff --git a/docker/php-fpm/supervisord.conf b/docker/php-fpm/supervisord.conf index bd9d7f7..5b3aa1e 100644 --- a/docker/php-fpm/supervisord.conf +++ b/docker/php-fpm/supervisord.conf @@ -44,4 +44,16 @@ user=www-data stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 \ No newline at end of file +stderr_logfile_maxbytes=0 + +;[program:messenger-consume] +;command=php bin/console messenger:consume orders_outbox inventory_outbox payments_outbox saga orders inventory payments +;autostart=true +;autorestart=true +;priority=20 +;startretries=1 +;user=www-data +;stdout_logfile=/dev/stdout +;stdout_logfile_maxbytes=0 +;stderr_logfile=/dev/stderr +;stderr_logfile_maxbytes=0 \ No newline at end of file diff --git a/src/Console/TestCompletePaymentConsoleCommand.php b/src/Console/TestCompletePaymentConsoleCommand.php new file mode 100644 index 0000000..d1693c8 --- /dev/null +++ b/src/Console/TestCompletePaymentConsoleCommand.php @@ -0,0 +1,30 @@ +paymentsUseCaseInteractor->completePayment('ff1605cadc83ee2595f4f27bd70a3609'); + + return Command::SUCCESS; + } +} diff --git a/src/Console/TestConsoleCommand.php b/src/Console/TestConsoleCommand.php index c4cc8ab..28bdf1d 100644 --- a/src/Console/TestConsoleCommand.php +++ b/src/Console/TestConsoleCommand.php @@ -4,9 +4,11 @@ namespace App\Console; +use App\Inventory\Application\UseCase\InventoryUseCaseInteractor; use App\Orders\Application\UseCase\OrdersUseCase; -use App\Training\Application\UseCase\TrainingUseCaseInteractor; -use App\Training\Domain\Aggregate\Material\Type; +use App\Orders\Domain\Aggregate\Order\OrderRepositoryInterface; +use App\Orders\Infrastructure\Adapter\Products\ProductsAdapter; +use App\Saga\CreateOrder\Service\Orchestrator\CreateOrderOrchestrator; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -19,17 +21,22 @@ final class TestConsoleCommand extends Command { public function __construct( private OrdersUseCase $ordersUseCase, - private TrainingUseCaseInteractor $trainingUseCaseInteractor + private ProductsAdapter $productsAdapter, + private InventoryUseCaseInteractor $inventoryUseCaseInteractor, + private OrderRepositoryInterface $orderRepository, + private CreateOrderOrchestrator $createOrderOrchestratorSagaService, ) { parent::__construct(); } protected function execute(InputInterface $input, OutputInterface $output): int { - $material = $this->trainingUseCaseInteractor - ->createMaterial('name', 'description', Type::VIDEO->value, 100) - ->material; - $this->ordersUseCase->createMaterialPurchaseOrder('customer_id', $material->id); + $this->inventoryUseCaseInteractor->addProduct('product_id', 10); + $product = $this->productsAdapter->findProduct('product_id'); + $orderId = $this->ordersUseCase + ->createOrder('customer_id', $product->id) + ->orderId; + // $this->createOrderOrchestratorSagaService->run($order->getId()); return Command::SUCCESS; } diff --git a/src/Inventory/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEvent.php b/src/Inventory/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEvent.php new file mode 100644 index 0000000..bb16229 --- /dev/null +++ b/src/Inventory/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEvent.php @@ -0,0 +1,22 @@ +invoiceId; + } + + public function getOrderId(): string + { + return $this->orderId; + } +} diff --git a/src/Inventory/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php b/src/Inventory/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php new file mode 100644 index 0000000..5572946 --- /dev/null +++ b/src/Inventory/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php @@ -0,0 +1,20 @@ +useCaseInteractor->releaseReservedProducts($event->getOrderId()); + } +} diff --git a/src/Payments/Application/ExternalEvents/OrderCreatedExternalEvent.php b/src/Inventory/Application/ExternalEvents/OrderCreated/OrderCreatedExternalEvent.php similarity index 91% rename from src/Payments/Application/ExternalEvents/OrderCreatedExternalEvent.php rename to src/Inventory/Application/ExternalEvents/OrderCreated/OrderCreatedExternalEvent.php index 3883509..abbc65a 100644 --- a/src/Payments/Application/ExternalEvents/OrderCreatedExternalEvent.php +++ b/src/Inventory/Application/ExternalEvents/OrderCreated/OrderCreatedExternalEvent.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Payments\Application\ExternalEvents; +namespace App\Inventory\Application\ExternalEvents\OrderCreated; final class OrderCreatedExternalEvent { diff --git a/src/Inventory/Application/ExternalEvents/OrderCreated/OrderCreatedExternalEventHandler.php b/src/Inventory/Application/ExternalEvents/OrderCreated/OrderCreatedExternalEventHandler.php new file mode 100644 index 0000000..210cf38 --- /dev/null +++ b/src/Inventory/Application/ExternalEvents/OrderCreated/OrderCreatedExternalEventHandler.php @@ -0,0 +1,21 @@ + $item['id'], $event->getItems()); + $this->useCaseInteractor->reserveItems($itemIds, $event->getOrderId()); + } +} diff --git a/src/Inventory/Application/UseCase/Command/AddProduct/AddProductCommand.php b/src/Inventory/Application/UseCase/Command/AddProduct/AddProductCommand.php new file mode 100644 index 0000000..00335c7 --- /dev/null +++ b/src/Inventory/Application/UseCase/Command/AddProduct/AddProductCommand.php @@ -0,0 +1,14 @@ +productService->add($command->productId, $command->quantity); + } +} diff --git a/src/Inventory/Application/UseCase/Command/ReleaseReservedProducts/ReleaseReservedProductsCommand.php b/src/Inventory/Application/UseCase/Command/ReleaseReservedProducts/ReleaseReservedProductsCommand.php new file mode 100644 index 0000000..47af4c0 --- /dev/null +++ b/src/Inventory/Application/UseCase/Command/ReleaseReservedProducts/ReleaseReservedProductsCommand.php @@ -0,0 +1,14 @@ +inventoryService->release($command->orderId); + } +} diff --git a/src/Inventory/Application/UseCase/Command/ReserveProducts/ReserveProductsCommand.php b/src/Inventory/Application/UseCase/Command/ReserveProducts/ReserveProductsCommand.php new file mode 100644 index 0000000..e8af927 --- /dev/null +++ b/src/Inventory/Application/UseCase/Command/ReserveProducts/ReserveProductsCommand.php @@ -0,0 +1,17 @@ + $productIds + */ + public function __construct(public array $productIds, public string $orderId) + { + } +} diff --git a/src/Inventory/Application/UseCase/Command/ReserveProducts/ReserveProductsCommandHandler.php b/src/Inventory/Application/UseCase/Command/ReserveProducts/ReserveProductsCommandHandler.php new file mode 100644 index 0000000..44ef411 --- /dev/null +++ b/src/Inventory/Application/UseCase/Command/ReserveProducts/ReserveProductsCommandHandler.php @@ -0,0 +1,20 @@ +inventoryService->reserve($command->productIds, $command->orderId); + } +} diff --git a/src/Inventory/Application/UseCase/InventoryUseCaseInteractor.php b/src/Inventory/Application/UseCase/InventoryUseCaseInteractor.php new file mode 100644 index 0000000..8f3a97b --- /dev/null +++ b/src/Inventory/Application/UseCase/InventoryUseCaseInteractor.php @@ -0,0 +1,35 @@ + $productIds + */ + public function reserveItems(array $productIds, string $orderId): void + { + $this->commandBus->execute(new ReserveProductsCommand($productIds, $orderId)); + } + + public function releaseReservedProducts(string $orderId): void + { + $this->commandBus->execute(new ReleaseReservedProductsCommand($orderId)); + } + + public function addProduct(string $productId, int $quantity): void + { + $this->commandBus->execute(new AddProductCommand($productId, $quantity)); + } +} diff --git a/src/Inventory/Domain/Aggregate/AggregateRoot.php b/src/Inventory/Domain/Aggregate/AggregateRoot.php new file mode 100644 index 0000000..8a18c54 --- /dev/null +++ b/src/Inventory/Domain/Aggregate/AggregateRoot.php @@ -0,0 +1,36 @@ +events; + $this->events = []; + + return $events; + } + + public function eventsEmpty(): bool + { + return empty($this->events); + } + + protected function registerDomainEvent(DomainEventInterface $event): void + { + $this->events[] = $event; + } +} diff --git a/src/Inventory/Domain/Aggregate/DomainEventInterface.php b/src/Inventory/Domain/Aggregate/DomainEventInterface.php new file mode 100755 index 0000000..fdea455 --- /dev/null +++ b/src/Inventory/Domain/Aggregate/DomainEventInterface.php @@ -0,0 +1,10 @@ +id = $id; + $this->quantity = $quantity; + } + + public function getId(): string + { + return $this->id; + } + + public function inStock(): bool + { + return $this->quantity > 0; + } + + public function reserve(int $number): void + { + Assert::greaterThan($this->quantity, $number, 'The number of products to reserve should be less than the quantity in stock'); + $this->quantity -= $number; + } + + public function release(int $number): void + { + $this->quantity += $number; + } + + public function getQuantity(): int + { + return $this->quantity; + } +} diff --git a/src/Inventory/Domain/Aggregate/Product/ProductRepositoryInterface.php b/src/Inventory/Domain/Aggregate/Product/ProductRepositoryInterface.php new file mode 100644 index 0000000..297c3b7 --- /dev/null +++ b/src/Inventory/Domain/Aggregate/Product/ProductRepositoryInterface.php @@ -0,0 +1,24 @@ + $ids + * + * @return array + */ + public function findProducts(array $ids): array; + + /** + * @param array $products + */ + public function saveBatch(array $products): void; +} diff --git a/src/Inventory/Domain/Aggregate/Product/ProductReservation.php b/src/Inventory/Domain/Aggregate/Product/ProductReservation.php new file mode 100644 index 0000000..6bbc269 --- /dev/null +++ b/src/Inventory/Domain/Aggregate/Product/ProductReservation.php @@ -0,0 +1,44 @@ +id = UlidService::generate(); + $this->productId = $productId; + $this->orderId = $orderId; + $this->quantity = $quantity; + } + + public function getId(): string + { + return $this->id; + } + + public function getProductId(): string + { + return $this->productId; + } + + public function getOrderId(): string + { + return $this->orderId; + } + + public function getQuantity(): int + { + return $this->quantity; + } +} diff --git a/src/Inventory/Domain/Aggregate/Product/ProductReservationRejectedDomainEvent.php b/src/Inventory/Domain/Aggregate/Product/ProductReservationRejectedDomainEvent.php new file mode 100644 index 0000000..e3fbbb7 --- /dev/null +++ b/src/Inventory/Domain/Aggregate/Product/ProductReservationRejectedDomainEvent.php @@ -0,0 +1,23 @@ + $productIds + */ + public function __construct(public array $productIds, public string $orderId) + { + } + + public function getType(): string + { + return EventType::INVENTORY_PRODUCTS_RESERVATION_REJECTED; + } +} diff --git a/src/Inventory/Domain/Aggregate/Product/ProductReservationRepositoryInterface.php b/src/Inventory/Domain/Aggregate/Product/ProductReservationRepositoryInterface.php new file mode 100644 index 0000000..ad5dbbe --- /dev/null +++ b/src/Inventory/Domain/Aggregate/Product/ProductReservationRepositoryInterface.php @@ -0,0 +1,20 @@ + $entities + */ + public function saveBatch(array $entities): void; + + /** + * @return array + */ + public function findByOrderId(string $orderId): array; + + public function removeBatch(array $productReservations): void; +} diff --git a/src/Inventory/Domain/Aggregate/Product/ProductsReservationReleasedDomainEvent.php b/src/Inventory/Domain/Aggregate/Product/ProductsReservationReleasedDomainEvent.php new file mode 100644 index 0000000..6842ecf --- /dev/null +++ b/src/Inventory/Domain/Aggregate/Product/ProductsReservationReleasedDomainEvent.php @@ -0,0 +1,23 @@ + $productIds + */ + public function __construct(public array $productIds, public string $orderId) + { + } + + public function getType(): string + { + return EventType::INVENTORY_PRODUCTS_RESERVATION_RELEASED; + } +} diff --git a/src/Inventory/Domain/Aggregate/Product/ProductsReservedDomainEvent.php b/src/Inventory/Domain/Aggregate/Product/ProductsReservedDomainEvent.php new file mode 100644 index 0000000..707c86d --- /dev/null +++ b/src/Inventory/Domain/Aggregate/Product/ProductsReservedDomainEvent.php @@ -0,0 +1,23 @@ + $productIds + */ + public function __construct(public array $productIds, public string $orderId) + { + } + + public function getType(): string + { + return EventType::INVENTORY_PRODUCTS_RESERVED; + } +} diff --git a/src/Inventory/Domain/Factory/ProductFactory.php b/src/Inventory/Domain/Factory/ProductFactory.php new file mode 100644 index 0000000..a35cf1f --- /dev/null +++ b/src/Inventory/Domain/Factory/ProductFactory.php @@ -0,0 +1,15 @@ + $productsIds + */ + public function reserve(array $productsIds, string $orderId): void + { + $products = $this->productRepository->findProducts($productsIds); + + try { + Assert::count($products, count($productsIds), 'Some products were not found'); + + $productReservations = []; + foreach ($products as $product) { + $product->reserve(1); + $productReservations[] = $this->productReservationFactory->create($product->getId(), $orderId, 1); + } + } catch (\Exception $e) { + $this->domainEventPublisher->publish( + new ProductReservationRejectedDomainEvent($productsIds, $orderId) + ); + + return; + } + + $this->productReservationRepository->saveBatch($productReservations); + $this->productRepository->saveBatch($products); + + $this->domainEventPublisher->publish( + new ProductsReservedDomainEvent($productsIds, $orderId) + ); + } + + /** + * Release products from an order. + */ + public function release(string $orderId): void + { + $productReservations = $this->productReservationRepository->findByOrderId($orderId); + $products = []; + foreach ($productReservations as $productReservation) { + $product = $this->productRepository->findOne($productReservation->getProductId()); + $product->release($productReservation->getQuantity()); + $products[] = $product; + } + + $this->productReservationRepository->removeBatch($productReservations); + $this->productRepository->saveBatch($products); + + $productIds = array_map(fn ($productReservation) => $productReservation->getProductId(), $productReservations); + $this->domainEventPublisher->publish( + new ProductsReservationReleasedDomainEvent($productIds, $orderId) + ); + } +} diff --git a/src/Inventory/Domain/Service/ProductService.php b/src/Inventory/Domain/Service/ProductService.php new file mode 100644 index 0000000..a9739e5 --- /dev/null +++ b/src/Inventory/Domain/Service/ProductService.php @@ -0,0 +1,23 @@ +productFactory->create($productId, $quantity); + $this->productRepository->save($product); + } +} diff --git a/src/Inventory/Infrastructure/Event/DomainEventProducer.php b/src/Inventory/Infrastructure/Event/DomainEventProducer.php new file mode 100644 index 0000000..c42bf55 --- /dev/null +++ b/src/Inventory/Infrastructure/Event/DomainEventProducer.php @@ -0,0 +1,39 @@ +wrapDomainEvent($event); + $stamps = [ + new AmqpStamp($event->getEventType()), + new DispatchAfterCurrentBusStamp(), + ]; + + $this->eventBus->dispatch($event, $stamps); + } + } + + private function wrapDomainEvent(DomainEventInterface $event): EventEnvelope + { + return new EventEnvelope( + $event->getType(), + $this->normalizer->normalize($event) + ); + } +} diff --git a/src/Inventory/Infrastructure/Event/DomainEventPublisher.php b/src/Inventory/Infrastructure/Event/DomainEventPublisher.php new file mode 100644 index 0000000..92be74d --- /dev/null +++ b/src/Inventory/Infrastructure/Event/DomainEventPublisher.php @@ -0,0 +1,21 @@ +outboxProducer->produce(...$events); + } +} diff --git a/src/Inventory/Infrastructure/Event/EventEnvelope.php b/src/Inventory/Infrastructure/Event/EventEnvelope.php new file mode 100644 index 0000000..a025dba --- /dev/null +++ b/src/Inventory/Infrastructure/Event/EventEnvelope.php @@ -0,0 +1,46 @@ +eventId = UlidService::generate(); + $this->eventTime = time(); + $this->eventType = $eventType; + $this->eventData = $eventData; + } + + public function getEventId(): string + { + return $this->eventId; + } + + public function getEventType(): string + { + return $this->eventType; + } + + public function getEventTime(): int + { + return $this->eventTime; + } + + public function getEventData(): array + { + return $this->eventData; + } +} diff --git a/src/Inventory/Infrastructure/Event/EventEnvelopeHandler.php b/src/Inventory/Infrastructure/Event/EventEnvelopeHandler.php new file mode 100644 index 0000000..ce9ab15 --- /dev/null +++ b/src/Inventory/Infrastructure/Event/EventEnvelopeHandler.php @@ -0,0 +1,34 @@ + OrderCreatedExternalEvent::class, + ]; + + public function __construct(private DenormalizerInterface $denormalizer, private MessageBusInterface $eventBus) + { + } + + public function __invoke(EventEnvelope $eventEnvelope): void + { + $class = self::EVENT_MAP[$eventEnvelope->getEventType()] ?? null; + if (null === $class) { + return; + } + + $domainEvent = $this->denormalizer->denormalize($eventEnvelope->getEventData(), $class); + $this->eventBus->dispatch($domainEvent); + } +} diff --git a/src/Inventory/Infrastructure/Event/EventEnvelopeSerializer.php b/src/Inventory/Infrastructure/Event/EventEnvelopeSerializer.php new file mode 100644 index 0000000..04de946 --- /dev/null +++ b/src/Inventory/Infrastructure/Event/EventEnvelopeSerializer.php @@ -0,0 +1,42 @@ +serializer->deserialize($encodedEnvelope['body'], EventEnvelope::class, 'json'); + } catch (ExceptionInterface $e) { + throw new MessageDecodingFailedException('Could not decode message: '.$e->getMessage(), $e->getCode(), $e); + } + + return new Envelope($message); + } + + public function encode(Envelope $envelope): array + { + return [ + 'body' => $this->serializer->serialize($envelope->getMessage(), 'json'), + 'headers' => ['Content-Type' => 'application/json'], + ]; + } +} diff --git a/src/Inventory/Infrastructure/Event/Outbox/OutboxMessage.php b/src/Inventory/Infrastructure/Event/Outbox/OutboxMessage.php new file mode 100644 index 0000000..913b460 --- /dev/null +++ b/src/Inventory/Infrastructure/Event/Outbox/OutboxMessage.php @@ -0,0 +1,30 @@ +id = UlidService::generate(); + $this->message = $message; + } + + public function getId(): string + { + return $this->id; + } + + public function getMessage(): DomainEventInterface + { + return $this->message; + } +} diff --git a/src/Inventory/Infrastructure/Event/Outbox/OutboxMessageProducer.php b/src/Inventory/Infrastructure/Event/Outbox/OutboxMessageProducer.php new file mode 100644 index 0000000..268e103 --- /dev/null +++ b/src/Inventory/Infrastructure/Event/Outbox/OutboxMessageProducer.php @@ -0,0 +1,23 @@ +bus->dispatch($message); + } + } +} diff --git a/src/Inventory/Infrastructure/Event/Outbox/OutboxMessageRelay.php b/src/Inventory/Infrastructure/Event/Outbox/OutboxMessageRelay.php new file mode 100644 index 0000000..b532002 --- /dev/null +++ b/src/Inventory/Infrastructure/Event/Outbox/OutboxMessageRelay.php @@ -0,0 +1,31 @@ +domainEventProducer->produce($outboxMessage->getMessage()); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + throw new RecoverableMessageHandlingException($e->getMessage(), 0, $e); + } + } +} diff --git a/src/Inventory/Infrastructure/ORM/Aggregate/Product.Product.orm.xml b/src/Inventory/Infrastructure/ORM/Aggregate/Product.Product.orm.xml new file mode 100644 index 0000000..26d8f8c --- /dev/null +++ b/src/Inventory/Infrastructure/ORM/Aggregate/Product.Product.orm.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/Inventory/Infrastructure/ORM/Aggregate/Product.ProductReservation.orm.xml b/src/Inventory/Infrastructure/ORM/Aggregate/Product.ProductReservation.orm.xml new file mode 100644 index 0000000..9af13ff --- /dev/null +++ b/src/Inventory/Infrastructure/ORM/Aggregate/Product.ProductReservation.orm.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Inventory/Infrastructure/Repository/ProductRepository.php b/src/Inventory/Infrastructure/Repository/ProductRepository.php new file mode 100644 index 0000000..71ad9f9 --- /dev/null +++ b/src/Inventory/Infrastructure/Repository/ProductRepository.php @@ -0,0 +1,43 @@ +find($id); + } + + public function save(Product $product): void + { + $this->_em->persist($product); + $this->_em->flush(); + } + + public function findProducts(array $ids): array + { + return $this->findBy(['id' => $ids]); + } + + public function saveBatch(array $products): void + { + foreach ($products as $product) { + $this->_em->persist($product); + } + + $this->_em->flush(); + } +} diff --git a/src/Inventory/Infrastructure/Repository/ProductReservationRepository.php b/src/Inventory/Infrastructure/Repository/ProductReservationRepository.php new file mode 100644 index 0000000..db03f2b --- /dev/null +++ b/src/Inventory/Infrastructure/Repository/ProductReservationRepository.php @@ -0,0 +1,41 @@ +_em->persist($entity); + } + + $this->_em->flush(); + } + + public function findByOrderId(string $orderId): array + { + return $this->findBy(['orderId' => $orderId]); + } + + public function removeBatch(array $productReservations): void + { + foreach ($productReservations as $productReservation) { + $this->_em->remove($productReservation); + } + + $this->_em->flush(); + } +} diff --git a/src/Orders/Application/Events/OrderPaid/OrderPaidEventListener.php b/src/Orders/Application/Events/OrderPaid/OrderPaidEventListener.php new file mode 100644 index 0000000..d0ec664 --- /dev/null +++ b/src/Orders/Application/Events/OrderPaid/OrderPaidEventListener.php @@ -0,0 +1,21 @@ +orderService->completeOrder($event->getOrderId()); + } +} diff --git a/src/Orders/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEvent.php b/src/Orders/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEvent.php new file mode 100644 index 0000000..2f410b1 --- /dev/null +++ b/src/Orders/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEvent.php @@ -0,0 +1,22 @@ +invoiceId; + } + + public function getOrderId(): string + { + return $this->orderId; + } +} diff --git a/src/Orders/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php b/src/Orders/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php new file mode 100644 index 0000000..84ee71b --- /dev/null +++ b/src/Orders/Application/ExternalEvents/InvoiceCancelled/InvoiceCancelledExternalEventHandler.php @@ -0,0 +1,20 @@ +orderService->cancelOrder($event->getOrderId()); + } +} diff --git a/src/Orders/Application/ExternalEvents/InvoicePaid/InvoicePaidExternalEvent.php b/src/Orders/Application/ExternalEvents/InvoicePaid/InvoicePaidExternalEvent.php new file mode 100644 index 0000000..d3e4f3d --- /dev/null +++ b/src/Orders/Application/ExternalEvents/InvoicePaid/InvoicePaidExternalEvent.php @@ -0,0 +1,22 @@ +invoiceId; + } + + public function getOrderId(): string + { + return $this->orderId; + } +} diff --git a/src/Orders/Application/ExternalEvents/InvoicePaid/InvoicePaidExternalEventHandler.php b/src/Orders/Application/ExternalEvents/InvoicePaid/InvoicePaidExternalEventHandler.php new file mode 100644 index 0000000..6d08827 --- /dev/null +++ b/src/Orders/Application/ExternalEvents/InvoicePaid/InvoicePaidExternalEventHandler.php @@ -0,0 +1,20 @@ +orderService->markPaid($event->getOrderId()); + } +} diff --git a/src/Orders/Application/ExternalEvents/ProductReservationRejected/ProductReservationRejectedExternalEvent.php b/src/Orders/Application/ExternalEvents/ProductReservationRejected/ProductReservationRejectedExternalEvent.php new file mode 100644 index 0000000..e0247e5 --- /dev/null +++ b/src/Orders/Application/ExternalEvents/ProductReservationRejected/ProductReservationRejectedExternalEvent.php @@ -0,0 +1,15 @@ + $productIds + */ + public function __construct(public array $productIds, public string $orderId) + { + } +} diff --git a/src/Orders/Application/ExternalEvents/ProductReservationRejected/ProductReservationRejectedExternalEventHandler.php b/src/Orders/Application/ExternalEvents/ProductReservationRejected/ProductReservationRejectedExternalEventHandler.php new file mode 100644 index 0000000..cd2d7d0 --- /dev/null +++ b/src/Orders/Application/ExternalEvents/ProductReservationRejected/ProductReservationRejectedExternalEventHandler.php @@ -0,0 +1,20 @@ +orderService->cancelOrder($event->orderId); + } +} diff --git a/src/Orders/Application/Service/Material/MaterialApiInterface.php b/src/Orders/Application/Service/Material/MaterialApiInterface.php deleted file mode 100644 index e423df9..0000000 --- a/src/Orders/Application/Service/Material/MaterialApiInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -api->findProduct($productId); + } +} diff --git a/src/Orders/Application/UseCase/Command/CreateMaterialPurchaseOrder/CreateMaterialPurchaseOrderCommand.php b/src/Orders/Application/UseCase/Command/CreateMaterialPurchaseOrder/CreateMaterialPurchaseOrderCommand.php deleted file mode 100644 index 5e2fa66..0000000 --- a/src/Orders/Application/UseCase/Command/CreateMaterialPurchaseOrder/CreateMaterialPurchaseOrderCommand.php +++ /dev/null @@ -1,14 +0,0 @@ -materialApi->findMaterial($command->materialId); - $order = $this->orderFactory->createPurchaseOrderForPaidMaterial( - $command->customerId, - $material->id, - $material->name, - $material->price - ); - - $this->orderRepository->save($order); - } -} diff --git a/src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommand.php b/src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommand.php new file mode 100644 index 0000000..f14b27f --- /dev/null +++ b/src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommand.php @@ -0,0 +1,14 @@ +productService->findProduct($command->productId); + $order = $this->orderService->createOrder( + $command->customerId, + new Product($product->id, $product->name, ProductType::MATERIAL), + $product->price + ); + + return new CreateOrderCommandResult($order->getId()); + } +} diff --git a/src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommandResult.php b/src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommandResult.php new file mode 100644 index 0000000..7e0c2ff --- /dev/null +++ b/src/Orders/Application/UseCase/Command/CreateOrder/CreateOrderCommandResult.php @@ -0,0 +1,12 @@ +commandBus->execute(new CreateMaterialPurchaseOrderCommand($customerId, $materialId)); + public function createOrder( + string $customerId, + string $productId + ): CreateOrderCommandResult { + return $this->commandBus->execute(new CreateOrderCommand($customerId, $productId)); } } diff --git a/src/Orders/Domain/Aggregate/Order/Order.php b/src/Orders/Domain/Aggregate/Order/Order.php index 709ae09..6e93285 100644 --- a/src/Orders/Domain/Aggregate/Order/Order.php +++ b/src/Orders/Domain/Aggregate/Order/Order.php @@ -33,26 +33,11 @@ public function __construct(string $customerId, int $totalPrice, PaymentMethod $ { $this->id = UlidService::generate(); $this->customerId = $customerId; - $this->status = OrderStatus::CREATED; - $this->items = new ArrayCollection(); $this->totalPrice = $totalPrice; $this->paymentMethod = $paymentMethod; $this->createdAt = new \DateTimeImmutable(); - } - - public function create(array $items): void - { - $this->items = new ArrayCollection($items); $this->status = OrderStatus::CREATED; - - $itemsEventData = $this->items->map(function (Item $item) { - return [ - 'id' => $item->getProduct()->getId(), - 'name' => $item->getProduct()->getName(), - 'price' => $item->getPrice(), - ]; - })->toArray(); - $this->registerDomainEvent(new OrderCreatedEvent($this->id, $this->customerId, $itemsEventData, $this->totalPrice)); + $this->items = new ArrayCollection(); } public function getId(): string @@ -84,4 +69,32 @@ public function getPaymentMethod(): PaymentMethod { return $this->paymentMethod; } + + public function setItems(array $items): void + { + $this->items = new ArrayCollection($items); + } + + public function setStatus(OrderStatus $status): void + { + $this->status = $status; + } + + public function cancel(): void + { + $this->status = OrderStatus::CANCELLED; + $this->registerDomainEvent(new OrderCancelledDomainEvent($this->id)); + } + + public function complete(): void + { + $this->status = OrderStatus::COMPLETED; + $this->registerDomainEvent(new OrderCompletedDomainEvent($this->id)); + } + + public function markAsPaid(): void + { + $this->status = OrderStatus::PAID; + $this->registerDomainEvent(new OrderPaidDomainEvent($this->id)); + } } diff --git a/src/Orders/Domain/Aggregate/Order/OrderCancelledDomainEvent.php b/src/Orders/Domain/Aggregate/Order/OrderCancelledDomainEvent.php new file mode 100644 index 0000000..1699265 --- /dev/null +++ b/src/Orders/Domain/Aggregate/Order/OrderCancelledDomainEvent.php @@ -0,0 +1,25 @@ +orderId; + } +} diff --git a/src/Orders/Domain/Aggregate/Order/OrderCompletedDomainEvent.php b/src/Orders/Domain/Aggregate/Order/OrderCompletedDomainEvent.php new file mode 100644 index 0000000..1f48b7b --- /dev/null +++ b/src/Orders/Domain/Aggregate/Order/OrderCompletedDomainEvent.php @@ -0,0 +1,25 @@ +orderId; + } +} diff --git a/src/Orders/Domain/Aggregate/Order/OrderCreatedEvent.php b/src/Orders/Domain/Aggregate/Order/OrderCreatedEvent.php index 0de1d30..300c352 100644 --- a/src/Orders/Domain/Aggregate/Order/OrderCreatedEvent.php +++ b/src/Orders/Domain/Aggregate/Order/OrderCreatedEvent.php @@ -13,7 +13,8 @@ public function __construct( private string $orderId, private string $customerId, private array $items, - private int $totalPrice + private int $totalPrice, + private string $paymentMethod ) { } @@ -41,4 +42,9 @@ public function getCustomerId(): string { return $this->customerId; } + + public function getPaymentMethod(): string + { + return $this->paymentMethod; + } } diff --git a/src/Orders/Domain/Aggregate/Order/OrderPaidDomainEvent.php b/src/Orders/Domain/Aggregate/Order/OrderPaidDomainEvent.php new file mode 100644 index 0000000..235380d --- /dev/null +++ b/src/Orders/Domain/Aggregate/Order/OrderPaidDomainEvent.php @@ -0,0 +1,25 @@ +orderId; + } +} diff --git a/src/Orders/Domain/Aggregate/Order/OrderRepositoryInterface.php b/src/Orders/Domain/Aggregate/Order/OrderRepositoryInterface.php index abba7a0..3458dd0 100644 --- a/src/Orders/Domain/Aggregate/Order/OrderRepositoryInterface.php +++ b/src/Orders/Domain/Aggregate/Order/OrderRepositoryInterface.php @@ -7,4 +7,6 @@ interface OrderRepositoryInterface { public function save(Order $order): void; + + public function findOneById(string $orderId): ?Order; } diff --git a/src/Orders/Domain/Factory/OrderFactory.php b/src/Orders/Domain/Factory/OrderFactory.php index 67506f7..94b682d 100644 --- a/src/Orders/Domain/Factory/OrderFactory.php +++ b/src/Orders/Domain/Factory/OrderFactory.php @@ -8,16 +8,13 @@ use App\Orders\Domain\Aggregate\Order\Order; use App\Orders\Domain\Aggregate\Order\PaymentMethod; use App\Orders\Domain\Aggregate\Order\Product; -use App\Orders\Domain\Aggregate\Order\ProductType; final class OrderFactory { - public function createPurchaseOrderForPaidMaterial(string $customerId, string $materialId, string $materialName, int $price): Order + public function create(string $customerId, Product $product, int $price): Order { $order = new Order($customerId, $price, PaymentMethod::CARD); - $order->create([ - new Item($order, new Product($materialId, $materialName, ProductType::MATERIAL), $price), - ]); + $order->setItems([new Item($order, $product, $price)]); return $order; } diff --git a/src/Orders/Domain/Service/DomainEventPublisherInterface.php b/src/Orders/Domain/Service/DomainEventPublisherInterface.php new file mode 100644 index 0000000..f509f6c --- /dev/null +++ b/src/Orders/Domain/Service/DomainEventPublisherInterface.php @@ -0,0 +1,12 @@ +orderFactory->create($customerId, $product, $price); + + $items = array_map( + fn ($item) => [ + 'id' => $item->getProduct()->getId(), + 'name' => $item->getProduct()->getName(), + 'price' => $item->getPrice(), + ], + $order->getItems()->toArray() + ); + + $events = [ + new OrderCreatedEvent( + $order->getId(), + $customerId, + $items, + $order->getTotalPrice(), + $order->getPaymentMethod()->value + ), + ]; + + $this->domainEventPublisher->publish(...$events); + $this->orderRepository->save($order); + + return $order; + } + + public function markPaid(string $orderId): void + { + $order = $this->orderRepository->findOneById($orderId); + $order->markAsPaid(); + $this->orderRepository->save($order); + } + + public function cancelOrder(string $orderId): void + { + $order = $this->orderRepository->findOneById($orderId); + $order->cancel(); + $this->orderRepository->save($order); + } + + public function completeOrder(string $orderId): void + { + $order = $this->orderRepository->findOneById($orderId); + $order->complete(); + $this->orderRepository->save($order); + } +} diff --git a/src/Orders/Infrastructure/Adapter/Materials/MaterialsAdapter.php b/src/Orders/Infrastructure/Adapter/Materials/MaterialsAdapter.php deleted file mode 100644 index 4373aab..0000000 --- a/src/Orders/Infrastructure/Adapter/Materials/MaterialsAdapter.php +++ /dev/null @@ -1,26 +0,0 @@ -trainingApi->findMaterial($materialId); - if (!$material) { - return null; - } - - return new MaterialDTO($material->id, $material->name, $material->price); - } -} diff --git a/src/Orders/Infrastructure/Adapter/Products/ProductsAdapter.php b/src/Orders/Infrastructure/Adapter/Products/ProductsAdapter.php new file mode 100644 index 0000000..7fbcad7 --- /dev/null +++ b/src/Orders/Infrastructure/Adapter/Products/ProductsAdapter.php @@ -0,0 +1,17 @@ +outboxProducer->produce(...$events); + } +} diff --git a/src/Orders/Infrastructure/Event/EventEnvelopeHandler.php b/src/Orders/Infrastructure/Event/EventEnvelopeHandler.php index 58dde95..6cabb29 100644 --- a/src/Orders/Infrastructure/Event/EventEnvelopeHandler.php +++ b/src/Orders/Infrastructure/Event/EventEnvelopeHandler.php @@ -4,7 +4,11 @@ namespace App\Orders\Infrastructure\Event; +use App\Orders\Application\ExternalEvents\InvoiceCancelled\InvoiceCancelledExternalEvent; +use App\Orders\Application\ExternalEvents\InvoicePaid\InvoicePaidExternalEvent; +use App\Orders\Application\ExternalEvents\ProductReservationRejected\ProductReservationRejectedExternalEvent; use App\Orders\Domain\Aggregate\Order\OrderCreatedEvent; +use App\Orders\Domain\Aggregate\Order\OrderPaidDomainEvent; use App\Shared\Domain\Event\EventType; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\MessageBusInterface; @@ -14,7 +18,11 @@ final class EventEnvelopeHandler { private const EVENT_MAP = [ + EventType::ORDERS_ORDER_PAID => OrderPaidDomainEvent::class, EventType::ORDERS_ORDER_CREATED => OrderCreatedEvent::class, + EventType::PAYMENTS_INVOICE_PAID => InvoicePaidExternalEvent::class, + EventType::PAYMENTS_INVOICE_CANCELLED => InvoiceCancelledExternalEvent::class, + EventType::INVENTORY_PRODUCTS_RESERVATION_REJECTED => ProductReservationRejectedExternalEvent::class, ]; public function __construct(private DenormalizerInterface $denormalizer, private MessageBusInterface $eventBus) diff --git a/src/Orders/Infrastructure/Repository/OrderRepository.php b/src/Orders/Infrastructure/Repository/OrderRepository.php index 87de463..cf0945d 100644 --- a/src/Orders/Infrastructure/Repository/OrderRepository.php +++ b/src/Orders/Infrastructure/Repository/OrderRepository.php @@ -21,4 +21,9 @@ public function save(Order $order): void $this->_em->persist($order); $this->_em->flush(); } + + public function findOneById(string $orderId): ?Order + { + return $this->find($orderId); + } } diff --git a/src/Payments/Application/Event/PaymentWasPaidEventHandler.php b/src/Payments/Application/Event/PaymentWasPaidEventHandler.php new file mode 100644 index 0000000..c2d9c8d --- /dev/null +++ b/src/Payments/Application/Event/PaymentWasPaidEventHandler.php @@ -0,0 +1,25 @@ +paymentRepository->findOne($event->paymentId); + $this->invoiceService->markPaid($payment->getInvoiceId()); + } +} diff --git a/src/Payments/Application/ExternalEvent/OrderCreated/OrderCreatedExternalEvent.php b/src/Payments/Application/ExternalEvent/OrderCreated/OrderCreatedExternalEvent.php new file mode 100644 index 0000000..4e6242a --- /dev/null +++ b/src/Payments/Application/ExternalEvent/OrderCreated/OrderCreatedExternalEvent.php @@ -0,0 +1,45 @@ +orderId; + } + + public function getItems(): array + { + return $this->items; + } + + public function getTotalPrice(): int + { + return $this->totalPrice; + } + + public function getCustomerId(): string + { + return $this->customerId; + } + + public function getPaymentMethod(): string + { + return $this->paymentMethod; + } +} diff --git a/src/Payments/Application/ExternalEvent/OrderCreated/OrderCreatedExternalEventHandler.php b/src/Payments/Application/ExternalEvent/OrderCreated/OrderCreatedExternalEventHandler.php new file mode 100644 index 0000000..3ca909d --- /dev/null +++ b/src/Payments/Application/ExternalEvent/OrderCreated/OrderCreatedExternalEventHandler.php @@ -0,0 +1,32 @@ + new Item($item['id'], $item['name'], $item['price']), + $event->getItems() + ); + $this->useCaseInteractor->createInvoice( + $event->getOrderId(), + $event->getCustomerId(), + $event->getTotalPrice(), + $items, + PaymentMethod::from($event->getPaymentMethod()) + ); + } +} diff --git a/src/Payments/Application/ExternalEvent/ProductsReserved/ProductsReservedExternalEvent.php b/src/Payments/Application/ExternalEvent/ProductsReserved/ProductsReservedExternalEvent.php new file mode 100644 index 0000000..4c373cd --- /dev/null +++ b/src/Payments/Application/ExternalEvent/ProductsReserved/ProductsReservedExternalEvent.php @@ -0,0 +1,25 @@ + $productIds + */ + public function __construct(public array $productIds, public string $orderId) + { + } + + public function getProductIds(): array + { + return $this->productIds; + } + + public function getOrderId(): string + { + return $this->orderId; + } +} diff --git a/src/Payments/Application/ExternalEvent/ProductsReserved/ProductsReservedExternalEventHandler.php b/src/Payments/Application/ExternalEvent/ProductsReserved/ProductsReservedExternalEventHandler.php new file mode 100644 index 0000000..4a59dec --- /dev/null +++ b/src/Payments/Application/ExternalEvent/ProductsReserved/ProductsReservedExternalEventHandler.php @@ -0,0 +1,24 @@ +invoiceRepository->findOneByOrderId($event->getOrderId()); + $this->useCaseInteractor->payInvoice($invoice->getId()); + } +} diff --git a/src/Payments/Application/ExternalEvents/OrderCreatedExternalEventHandler.php b/src/Payments/Application/ExternalEvents/OrderCreatedExternalEventHandler.php deleted file mode 100644 index eda72de..0000000 --- a/src/Payments/Application/ExternalEvents/OrderCreatedExternalEventHandler.php +++ /dev/null @@ -1,34 +0,0 @@ -getItems() as $item) { - $items[] = new Item($item['id'], $item['name'], $item['price']); - } - $invoice = $this->invoiceFactory->create( - $event->getOrderId(), - $event->getCustomerId(), - $event->getTotalPrice(), - $items - ); - $this->invoiceRepository->save($invoice); - } -} diff --git a/src/Payments/Application/Service/PaymentGateway/PaymentGatewayInterface.php b/src/Payments/Application/Service/PaymentGateway/PaymentGatewayInterface.php new file mode 100644 index 0000000..5be617d --- /dev/null +++ b/src/Payments/Application/Service/PaymentGateway/PaymentGatewayInterface.php @@ -0,0 +1,14 @@ +status; + } + + public function isAwaitingPayment(): bool + { + return Status::AWAITING_PAYMENT === $this->status; + } + + public function isFailed(): bool + { + return Status::FAILED === $this->status; + } +} diff --git a/src/Payments/Application/UseCase/Command/CompletePayment/CompletePaymentCommand.php b/src/Payments/Application/UseCase/Command/CompletePayment/CompletePaymentCommand.php new file mode 100644 index 0000000..82826f5 --- /dev/null +++ b/src/Payments/Application/UseCase/Command/CompletePayment/CompletePaymentCommand.php @@ -0,0 +1,14 @@ +paymentRepository->findOneByExternalId($command->externalPaymentId); + if ($payment->isAwaiting()) { + $status = $this->paymentGateway->checkStatus($payment->getExternalPaymentId()); + if ($status->isPaid()) { + $payment->markPaid(); + } elseif ($status->isFailed()) { + $payment->markFailed(); + } + + $payment->setResponse($status->response); + $this->paymentRepository->save($payment); + } + } +} diff --git a/src/Payments/Application/UseCase/Command/CreateInvoice/CreateInvoiceCommand.php b/src/Payments/Application/UseCase/Command/CreateInvoice/CreateInvoiceCommand.php new file mode 100644 index 0000000..d5019fd --- /dev/null +++ b/src/Payments/Application/UseCase/Command/CreateInvoice/CreateInvoiceCommand.php @@ -0,0 +1,24 @@ + $items + */ + public function __construct( + public string $orderId, + public string $customerId, + public int $amount, + public array $items, + public PaymentMethod $paymentMethod + ) { + } +} diff --git a/src/Payments/Application/UseCase/Command/CreateInvoice/CreateInvoiceCommandHandler.php b/src/Payments/Application/UseCase/Command/CreateInvoice/CreateInvoiceCommandHandler.php new file mode 100644 index 0000000..32ebeba --- /dev/null +++ b/src/Payments/Application/UseCase/Command/CreateInvoice/CreateInvoiceCommandHandler.php @@ -0,0 +1,27 @@ +invoiceService->create( + $command->orderId, + $command->customerId, + $command->amount, + $command->items, + $command->paymentMethod + ); + } +} diff --git a/src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommand.php b/src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommand.php new file mode 100644 index 0000000..0b263b9 --- /dev/null +++ b/src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommand.php @@ -0,0 +1,14 @@ +invoiceRepository->findOne($command->invoiceId); + Assert::true($invoice->canPay(), 'Invoice cannot be paid'); + + $payment = $this->paymentService->createPayment( + $invoice->getId(), + $invoice->getCustomerId(), + $invoice->getAmount(), + $invoice->getPaymentMethod() + ); + + $result = $this->paymentGateway->pay($payment, $command->returnUrl); + if ($result->success) { + $payment->setExternalPaymentId($result->externalPaymentId); + $payment->markAwaitingPaymentConfirmation(); + } else { + $payment->markFailed(); + } + + $payment->setResponse($result->response); + $this->paymentRepository->save($payment); + + return new PayInvoiceCommandResult( + $payment->getId(), + $payment->getStatus(), + $payment->getExternalPaymentId(), + $result->confirmationUrl + ); + } +} diff --git a/src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommandResult.php b/src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommandResult.php new file mode 100644 index 0000000..274ad26 --- /dev/null +++ b/src/Payments/Application/UseCase/Command/PayInvoice/PayInvoiceCommandResult.php @@ -0,0 +1,18 @@ + $items + */ + public function createInvoice( + string $orderId, + string $customerId, + int $amount, + array $items, + PaymentMethod $paymentMethod + ): void { + $this->commandBus->execute( + new CreateInvoiceCommand( + $orderId, $customerId, $amount, $items, $paymentMethod + ) + ); + } + + public function completePayment(string $externalPaymentId): void + { + $this->commandBus->execute(new CompletePaymentCommand($externalPaymentId)); + } + + public function payInvoice(string $invoiceId): void + { + $this->commandBus->execute(new PayInvoiceCommand($invoiceId, null)); + } +} diff --git a/src/Payments/Domain/Aggregate/Invoice/Invoice.php b/src/Payments/Domain/Aggregate/Invoice/Invoice.php index d8fd490..2393324 100644 --- a/src/Payments/Domain/Aggregate/Invoice/Invoice.php +++ b/src/Payments/Domain/Aggregate/Invoice/Invoice.php @@ -5,12 +5,13 @@ namespace App\Payments\Domain\Aggregate\Invoice; use App\Payments\Domain\Aggregate\AggregateRoot; +use App\Payments\Domain\Aggregate\Payment\PaymentMethod; use App\Shared\Domain\Service\UlidService; /** * Счет на оплату. */ -final class Invoice extends AggregateRoot +class Invoice extends AggregateRoot { private string $id; private string $orderId; @@ -23,6 +24,7 @@ final class Invoice extends AggregateRoot * @var array */ private array $items; + private PaymentMethod $paymentMethod; private \DateTimeImmutable $createdAt; @@ -31,7 +33,7 @@ final class Invoice extends AggregateRoot /** * @param array $items */ - public function __construct(string $orderId, string $customerId, int $amount, array $items) + public function __construct(string $orderId, string $customerId, int $amount, array $items, PaymentMethod $paymentMethod) { $this->id = UlidService::generate(); $this->orderId = $orderId; @@ -40,6 +42,7 @@ public function __construct(string $orderId, string $customerId, int $amount, ar $this->amount = $amount; $this->items = $items; $this->createdAt = new \DateTimeImmutable(); + $this->paymentMethod = $paymentMethod; } public function getId(): string @@ -47,13 +50,65 @@ public function getId(): string return $this->id; } - public function paid(): void + public function markPaid(): void { $this->status = Status::PAID; + $this->registerDomainEvent(new InvoicePaidDomainEvent('invoiceId', $this->orderId)); } - public function cancelled(): void + public function cancel(): void { $this->status = Status::CANCELLED; + $this->registerDomainEvent(new InvoiceCancelledDomainEvent('invoiceId', $this->orderId)); + } + + public function getOrderId(): string + { + return $this->orderId; + } + + public function getCustomerId(): string + { + return $this->customerId; + } + + public function getStatus(): Status + { + return $this->status; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function getItems(): array + { + return $this->items; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTime + { + return $this->updatedAt; + } + + public function getPaymentMethod(): PaymentMethod + { + return $this->paymentMethod; + } + + public function isCreated(): bool + { + return $this->status->isCreated(); + } + + public function canPay(): bool + { + return $this->status->isCreated(); } } diff --git a/src/Payments/Domain/Aggregate/Invoice/InvoiceCancelledDomainEvent.php b/src/Payments/Domain/Aggregate/Invoice/InvoiceCancelledDomainEvent.php new file mode 100644 index 0000000..7057df7 --- /dev/null +++ b/src/Payments/Domain/Aggregate/Invoice/InvoiceCancelledDomainEvent.php @@ -0,0 +1,30 @@ +invoiceId; + } + + public function getOrderId(): string + { + return $this->orderId; + } +} diff --git a/src/Payments/Domain/Aggregate/Invoice/InvoicePaidDomainEvent.php b/src/Payments/Domain/Aggregate/Invoice/InvoicePaidDomainEvent.php index 7476887..7397f5e 100644 --- a/src/Payments/Domain/Aggregate/Invoice/InvoicePaidDomainEvent.php +++ b/src/Payments/Domain/Aggregate/Invoice/InvoicePaidDomainEvent.php @@ -9,8 +9,22 @@ final class InvoicePaidDomainEvent implements DomainEventInterface { + public function __construct(private string $invoiceId, private string $orderId) + { + } + public function getType(): string { return EventType::PAYMENTS_INVOICE_PAID; } + + public function getInvoiceId(): string + { + return $this->invoiceId; + } + + public function getOrderId(): string + { + return $this->orderId; + } } diff --git a/src/Payments/Domain/Aggregate/Invoice/InvoiceRepositoryInterface.php b/src/Payments/Domain/Aggregate/Invoice/InvoiceRepositoryInterface.php index 84c2bb3..8c4f3f1 100644 --- a/src/Payments/Domain/Aggregate/Invoice/InvoiceRepositoryInterface.php +++ b/src/Payments/Domain/Aggregate/Invoice/InvoiceRepositoryInterface.php @@ -7,4 +7,8 @@ interface InvoiceRepositoryInterface { public function save(Invoice $invoice): void; + + public function findOne(string $invoiceId): ?Invoice; + + public function findOneByOrderId(string $orderId): ?Invoice; } diff --git a/src/Payments/Domain/Aggregate/Invoice/Status.php b/src/Payments/Domain/Aggregate/Invoice/Status.php index 1f4d503..d7cb767 100644 --- a/src/Payments/Domain/Aggregate/Invoice/Status.php +++ b/src/Payments/Domain/Aggregate/Invoice/Status.php @@ -12,4 +12,9 @@ enum Status: string case CREATED = 'created'; case PAID = 'paid'; case CANCELLED = 'cancelled'; + + public function isCreated(): bool + { + return self::CREATED === $this; + } } diff --git a/src/Payments/Domain/Aggregate/Payment/Payment.php b/src/Payments/Domain/Aggregate/Payment/Payment.php index b1bf324..358a701 100644 --- a/src/Payments/Domain/Aggregate/Payment/Payment.php +++ b/src/Payments/Domain/Aggregate/Payment/Payment.php @@ -7,31 +7,31 @@ use App\Payments\Domain\Aggregate\AggregateRoot; use App\Shared\Domain\Service\UlidService; -final class Payment extends AggregateRoot +class Payment extends AggregateRoot { private string $id; private string $invoiceId; private string $customerId; + private ?string $externalPaymentId = null; private Status $status; - private Gateway $gateway; - private string $externalTransactionId; + private PaymentMethod $paymentMethod; private int $amount; - private array $response; + private array $response = []; private \DateTimeImmutable $createdAt; - private ?\DateTimeImmutable $updatedAt = null; + private ?\DateTime $updatedAt = null; - public function __construct(string $invoiceId, string $customerId, int $amount, Gateway $gateway) + public function __construct(string $invoiceId, string $customerId, int $amount, PaymentMethod $paymentMethod) { $this->id = UlidService::generate(); $this->invoiceId = $invoiceId; $this->customerId = $customerId; $this->status = Status::CREATED; $this->amount = $amount; - $this->gateway = $gateway; + $this->paymentMethod = $paymentMethod; $this->createdAt = new \DateTimeImmutable(); } @@ -39,4 +39,85 @@ public function getId(): string { return $this->id; } + + public function markPaid(): void + { + $this->status = Status::PAID; + $this->registerDomainEvent(new PaymentWasPaidDomainEvent($this->id)); + } + + public function getInvoiceId(): string + { + return $this->invoiceId; + } + + public function getCustomerId(): string + { + return $this->customerId; + } + + public function getStatus(): Status + { + return $this->status; + } + + public function getPaymentMethod(): PaymentMethod + { + return $this->paymentMethod; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function getResponse(): array + { + return $this->response; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTime + { + return $this->updatedAt; + } + + public function getExternalPaymentId(): ?string + { + return $this->externalPaymentId; + } + + public function setExternalPaymentId(?string $externalPaymentId): void + { + $this->externalPaymentId = $externalPaymentId; + } + + public function setResponse(array $response): void + { + $this->response = $response; + } + + public function markFailed(): void + { + $this->status = Status::FAILED; + } + + public function markAwaitingPaymentConfirmation(): void + { + $this->status = Status::AWAITING_PAYMENT_CONFIRMATION; + } + + public function isAwaiting(): bool + { + return Status::AWAITING_PAYMENT_CONFIRMATION === $this->status; + } + + public function isPaid(): bool + { + return Status::PAID === $this->status; + } } diff --git a/src/Payments/Domain/Aggregate/Payment/Gateway.php b/src/Payments/Domain/Aggregate/Payment/PaymentMethod.php similarity index 51% rename from src/Payments/Domain/Aggregate/Payment/Gateway.php rename to src/Payments/Domain/Aggregate/Payment/PaymentMethod.php index 91efdf5..a353809 100644 --- a/src/Payments/Domain/Aggregate/Payment/Gateway.php +++ b/src/Payments/Domain/Aggregate/Payment/PaymentMethod.php @@ -4,8 +4,7 @@ namespace App\Payments\Domain\Aggregate\Payment; -enum Gateway: string +enum PaymentMethod: string { - case YOOMONEY = 'yoomoney'; - case TINKOFF = 'tinkoff'; + case CARD = 'card'; } diff --git a/src/Payments/Domain/Aggregate/Payment/PaymentRepositoryInterface.php b/src/Payments/Domain/Aggregate/Payment/PaymentRepositoryInterface.php new file mode 100644 index 0000000..8173199 --- /dev/null +++ b/src/Payments/Domain/Aggregate/Payment/PaymentRepositoryInterface.php @@ -0,0 +1,14 @@ + $items */ - public function create(string $orderId, string $customerId, int $amount, array $items): Invoice + public function create(string $orderId, string $customerId, int $amount, array $items, PaymentMethod $paymentMethod): Invoice { - return new Invoice($orderId, $customerId, $amount, $items); + return new Invoice($orderId, $customerId, $amount, $items, $paymentMethod); } } diff --git a/src/Payments/Domain/Factory/PaymentFactory.php b/src/Payments/Domain/Factory/PaymentFactory.php new file mode 100644 index 0000000..32b95ec --- /dev/null +++ b/src/Payments/Domain/Factory/PaymentFactory.php @@ -0,0 +1,22 @@ +getId(), $invoice->getCustomerId(), $invoice->getAmount(), $invoice->getPaymentMethod()); + } +} diff --git a/src/Payments/Domain/Service/DomainEventPublisherInterface.php b/src/Payments/Domain/Service/DomainEventPublisherInterface.php new file mode 100644 index 0000000..486c9a4 --- /dev/null +++ b/src/Payments/Domain/Service/DomainEventPublisherInterface.php @@ -0,0 +1,12 @@ +invoiceRepository->findOne($invoiceId); + $invoice->markPaid(); + $this->invoiceRepository->save($invoice); + } + + /** + * @param array $items + */ + public function create(string $orderId, string $customerId, int $amount, array $items, PaymentMethod $paymentMethod): Invoice + { + $invoice = $this->invoiceFactory->create($orderId, $customerId, $amount, $items, $paymentMethod); + $this->invoiceRepository->save($invoice); + + return $invoice; + } +} diff --git a/src/Payments/Domain/Service/PaymentService.php b/src/Payments/Domain/Service/PaymentService.php new file mode 100644 index 0000000..add213a --- /dev/null +++ b/src/Payments/Domain/Service/PaymentService.php @@ -0,0 +1,31 @@ +paymentFactory->create($invoiceId, $customerId, $amount, $paymentMethod); + $this->paymentRepository->save($payment); + + return $payment; + } +} diff --git a/src/Payments/Infrastructure/Event/DomainEventProducer.php b/src/Payments/Infrastructure/Event/DomainEventProducer.php index e0d2850..282091e 100644 --- a/src/Payments/Infrastructure/Event/DomainEventProducer.php +++ b/src/Payments/Infrastructure/Event/DomainEventProducer.php @@ -5,7 +5,9 @@ namespace App\Payments\Infrastructure\Event; use App\Payments\Domain\Aggregate\DomainEventInterface; +use Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpStamp; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Messenger\Stamp\DispatchAfterCurrentBusStamp; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; class DomainEventProducer @@ -14,20 +16,20 @@ public function __construct(private MessageBusInterface $eventBus, private Norma { } - public function produce(object ...$events): void + public function produce(DomainEventInterface ...$events): void { foreach ($events as $event) { - $stamps = []; - if ($event instanceof DomainEventInterface) { - $event = $this->wrap($event); - // $stamps[] = new AmqpStamp($event->getEventType()); - } + $event = $this->wrapDomainEvent($event); + $stamps = [ + new AmqpStamp($event->getEventType()), + new DispatchAfterCurrentBusStamp(), + ]; $this->eventBus->dispatch($event, $stamps); } } - private function wrap(DomainEventInterface $event): EventEnvelope + private function wrapDomainEvent(DomainEventInterface $event): EventEnvelope { return new EventEnvelope( $event->getType(), diff --git a/src/Payments/Infrastructure/Event/DomainEventPublisher.php b/src/Payments/Infrastructure/Event/DomainEventPublisher.php new file mode 100644 index 0000000..a7d2c19 --- /dev/null +++ b/src/Payments/Infrastructure/Event/DomainEventPublisher.php @@ -0,0 +1,21 @@ +outboxProducer->produce(...$events); + } +} diff --git a/src/Payments/Infrastructure/Event/EventEnvelopeHandler.php b/src/Payments/Infrastructure/Event/EventEnvelopeHandler.php index 54d0a41..f5ddfd9 100644 --- a/src/Payments/Infrastructure/Event/EventEnvelopeHandler.php +++ b/src/Payments/Infrastructure/Event/EventEnvelopeHandler.php @@ -4,7 +4,9 @@ namespace App\Payments\Infrastructure\Event; -use App\Payments\Application\ExternalEvents\OrderCreatedExternalEvent; +use App\Payments\Application\ExternalEvent\OrderCreated\OrderCreatedExternalEvent; +use App\Payments\Application\ExternalEvent\ProductsReserved\ProductsReservedExternalEvent; +use App\Payments\Domain\Aggregate\Payment\PaymentWasPaidDomainEvent; use App\Shared\Domain\Event\EventType; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\MessageBusInterface; @@ -13,8 +15,10 @@ #[AsMessageHandler] final class EventEnvelopeHandler { - private const MAP = [ + private const EVENT_MAP = [ EventType::ORDERS_ORDER_CREATED => OrderCreatedExternalEvent::class, + EventType::INVENTORY_PRODUCTS_RESERVED => ProductsReservedExternalEvent::class, + EventType::PAYMENTS_PAYMENT_PAID => PaymentWasPaidDomainEvent::class, ]; public function __construct(private DenormalizerInterface $denormalizer, private MessageBusInterface $eventBus) @@ -23,18 +27,12 @@ public function __construct(private DenormalizerInterface $denormalizer, private public function __invoke(EventEnvelope $eventEnvelope): void { - $domainEvent = $this->denormalizer->denormalize( - $eventEnvelope->getEventData(), - $this->getClassByType($eventEnvelope->getEventType()) - ); - $this->eventBus->dispatch($domainEvent); - } + $class = self::EVENT_MAP[$eventEnvelope->getEventType()] ?? null; + if (null === $class) { + return; + } - /** - * @return class-string|null - */ - private function getClassByType(string $type): ?string - { - return self::MAP[$type] ?? null; + $domainEvent = $this->denormalizer->denormalize($eventEnvelope->getEventData(), $class); + $this->eventBus->dispatch($domainEvent); } } diff --git a/src/Payments/Infrastructure/Event/Outbox/OutboxMessage.php b/src/Payments/Infrastructure/Event/Outbox/OutboxMessage.php new file mode 100644 index 0000000..ea5eb1c --- /dev/null +++ b/src/Payments/Infrastructure/Event/Outbox/OutboxMessage.php @@ -0,0 +1,30 @@ +id = UlidService::generate(); + $this->message = $message; + } + + public function getId(): string + { + return $this->id; + } + + public function getMessage(): DomainEventInterface + { + return $this->message; + } +} diff --git a/src/Payments/Infrastructure/Event/Outbox/OutboxMessageProducer.php b/src/Payments/Infrastructure/Event/Outbox/OutboxMessageProducer.php new file mode 100644 index 0000000..80a775f --- /dev/null +++ b/src/Payments/Infrastructure/Event/Outbox/OutboxMessageProducer.php @@ -0,0 +1,23 @@ +bus->dispatch($message); + } + } +} diff --git a/src/Payments/Infrastructure/Event/Outbox/OutboxMessageRelay.php b/src/Payments/Infrastructure/Event/Outbox/OutboxMessageRelay.php new file mode 100644 index 0000000..e5c1513 --- /dev/null +++ b/src/Payments/Infrastructure/Event/Outbox/OutboxMessageRelay.php @@ -0,0 +1,31 @@ +domainEventProducer->produce($outboxMessage->getMessage()); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + throw new RecoverableMessageHandlingException($e->getMessage(), 0, $e); + } + } +} diff --git a/src/Payments/Infrastructure/Event/PublishAggregateEventsOnFlushListener.php b/src/Payments/Infrastructure/Event/PublishAggregateEventsOnFlushListener.php index a0f81bb..29420c8 100644 --- a/src/Payments/Infrastructure/Event/PublishAggregateEventsOnFlushListener.php +++ b/src/Payments/Infrastructure/Event/PublishAggregateEventsOnFlushListener.php @@ -5,6 +5,7 @@ namespace App\Payments\Infrastructure\Event; use App\Payments\Domain\Aggregate\AggregateRoot; +use App\Payments\Infrastructure\Event\Outbox\OutboxMessageProducer; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Events; @@ -12,7 +13,7 @@ #[AsDoctrineListener(event: Events::onFlush)] final readonly class PublishAggregateEventsOnFlushListener { - public function __construct(private DomainEventProducer $eventProducer) + public function __construct(private OutboxMessageProducer $outboxProducer) { } @@ -48,7 +49,7 @@ public function onFlush(OnFlushEventArgs $eventArgs): void private function publishDomainEvent(object $entity): void { if ($entity instanceof AggregateRoot && !$entity->eventsEmpty()) { - $this->eventProducer->produce(...$entity->getDomainEvents()); + $this->outboxProducer->produce(...$entity->getDomainEvents()); } } } diff --git a/src/Payments/Infrastructure/ORM/Aggregate/Invoice.Invoice.orm.xml b/src/Payments/Infrastructure/ORM/Aggregate/Invoice.Invoice.orm.xml index b98a3a2..4b64480 100644 --- a/src/Payments/Infrastructure/ORM/Aggregate/Invoice.Invoice.orm.xml +++ b/src/Payments/Infrastructure/ORM/Aggregate/Invoice.Invoice.orm.xml @@ -12,6 +12,7 @@ + diff --git a/src/Payments/Infrastructure/ORM/Aggregate/Payment.Payment.orm.xml b/src/Payments/Infrastructure/ORM/Aggregate/Payment.Payment.orm.xml new file mode 100644 index 0000000..07543b4 --- /dev/null +++ b/src/Payments/Infrastructure/ORM/Aggregate/Payment.Payment.orm.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Payments/Infrastructure/ORM/Type/InvoiceItemsType.php b/src/Payments/Infrastructure/ORM/Type/InvoiceItemsType.php index e9c9b2d..3219a6b 100644 --- a/src/Payments/Infrastructure/ORM/Type/InvoiceItemsType.php +++ b/src/Payments/Infrastructure/ORM/Type/InvoiceItemsType.php @@ -26,12 +26,9 @@ public function getName(): string public function convertToPHPValue($value, AbstractPlatform $platform): mixed { - /** @var Item[] $items */ - $items = []; - $serializer = new Serializer(); - $serializer->deserialize($items, 'Item[]', 'json'); + $serializer = new Serializer([new ArrayDenormalizer(), new ObjectNormalizer()], [new JsonEncoder()]); - return $items; + return $serializer->deserialize($value, Item::class.'[]', 'json'); } public function convertToDatabaseValue($value, AbstractPlatform $platform): mixed diff --git a/src/Payments/Infrastructure/Repository/InvoiceRepository.php b/src/Payments/Infrastructure/Repository/InvoiceRepository.php index 285ac95..305ef88 100644 --- a/src/Payments/Infrastructure/Repository/InvoiceRepository.php +++ b/src/Payments/Infrastructure/Repository/InvoiceRepository.php @@ -21,4 +21,14 @@ public function save(Invoice $invoice): void $this->_em->persist($invoice); $this->_em->flush(); } + + public function findOne(string $invoiceId): ?Invoice + { + return $this->find($invoiceId); + } + + public function findOneByOrderId(string $orderId): ?Invoice + { + return $this->findOneBy(['orderId' => $orderId]); + } } diff --git a/src/Payments/Infrastructure/Repository/PaymentRepository.php b/src/Payments/Infrastructure/Repository/PaymentRepository.php new file mode 100644 index 0000000..14e2660 --- /dev/null +++ b/src/Payments/Infrastructure/Repository/PaymentRepository.php @@ -0,0 +1,34 @@ +_em->persist($payment); + $this->_em->flush(); + } + + public function findOne(string $paymentId): ?Payment + { + return $this->find($paymentId); + } + + public function findOneByExternalId(string $externalPaymentId): ?Payment + { + return $this->findOneBy(['externalPaymentId' => $externalPaymentId]); + } +} diff --git a/src/Payments/Infrastructure/Service/PaymentDummyGatewayService.php b/src/Payments/Infrastructure/Service/PaymentDummyGatewayService.php new file mode 100644 index 0000000..d3756af --- /dev/null +++ b/src/Payments/Infrastructure/Service/PaymentDummyGatewayService.php @@ -0,0 +1,44 @@ + 'success'], + true, + null + ); + } catch (\Exception $e) { + return new PaymentResult( + null, + [ + 'status' => 'failed', + 'message' => $e->getMessage(), + ], + false, + null + ); + } + } + + public function checkStatus(string $externalPaymentId): StatusResult + { + return new StatusResult( + ['status' => 'paid'], + Status::PAID + ); + } +} diff --git a/src/Saga/CreateOrder/Adapter/InventoryService.php b/src/Saga/CreateOrder/Adapter/InventoryService.php new file mode 100644 index 0000000..001ba6a --- /dev/null +++ b/src/Saga/CreateOrder/Adapter/InventoryService.php @@ -0,0 +1,16 @@ +orderId = $orderId; + $this->state = $state; + $this->payload = $payload; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): string + { + return $this->id; + } + + public function setId(string $id): CreateOrderSagaEntity + { + $this->id = $id; + + return $this; + } + + public function getOrderId(): string + { + return $this->orderId; + } + + public function setOrderId(string $orderId): CreateOrderSagaEntity + { + $this->orderId = $orderId; + + return $this; + } + + public function getPayload(): string + { + return $this->payload; + } + + public function setPayload(string $payload): CreateOrderSagaEntity + { + $this->payload = $payload; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): CreateOrderSagaEntity + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getState(): State + { + return $this->state; + } + + public function setState(State $state): void + { + $this->state = $state; + } +} diff --git a/src/Saga/CreateOrder/Entity/State.php b/src/Saga/CreateOrder/Entity/State.php new file mode 100644 index 0000000..572392d --- /dev/null +++ b/src/Saga/CreateOrder/Entity/State.php @@ -0,0 +1,31 @@ +createOrderChoreographySagaService->handleInvoiceCancelledEvent($event); + } +} diff --git a/src/Saga/CreateOrder/ExternalEvent/InvoicePaid/InvoicePaidExternalEvent.php b/src/Saga/CreateOrder/ExternalEvent/InvoicePaid/InvoicePaidExternalEvent.php new file mode 100644 index 0000000..67df5fa --- /dev/null +++ b/src/Saga/CreateOrder/ExternalEvent/InvoicePaid/InvoicePaidExternalEvent.php @@ -0,0 +1,12 @@ +createOrderChoreographySagaService->handleInvoicePaidEvent($event); + } +} diff --git a/src/Saga/CreateOrder/ExternalEvent/OrderCancelled/OrderCancelledExternalEvent.php b/src/Saga/CreateOrder/ExternalEvent/OrderCancelled/OrderCancelledExternalEvent.php new file mode 100644 index 0000000..26fb8e5 --- /dev/null +++ b/src/Saga/CreateOrder/ExternalEvent/OrderCancelled/OrderCancelledExternalEvent.php @@ -0,0 +1,12 @@ +createOrderChoreographySagaService->handleOrderCancelledEvent($event); + } +} diff --git a/src/Saga/CreateOrder/ExternalEvent/OrderCompleted/OrderCompletedExternalEvent.php b/src/Saga/CreateOrder/ExternalEvent/OrderCompleted/OrderCompletedExternalEvent.php new file mode 100644 index 0000000..e32cc58 --- /dev/null +++ b/src/Saga/CreateOrder/ExternalEvent/OrderCompleted/OrderCompletedExternalEvent.php @@ -0,0 +1,12 @@ +createOrderChoreographySagaService->handleOrderCompletedEvent($event); + } +} diff --git a/src/Saga/CreateOrder/ExternalEvent/OrderCreated/OrderCreatedExternalEvent.php b/src/Saga/CreateOrder/ExternalEvent/OrderCreated/OrderCreatedExternalEvent.php new file mode 100644 index 0000000..b66f0fb --- /dev/null +++ b/src/Saga/CreateOrder/ExternalEvent/OrderCreated/OrderCreatedExternalEvent.php @@ -0,0 +1,16 @@ +createOrderChoreographySagaService->handleOrderCreatedEvent($event); + } +} diff --git a/src/Saga/CreateOrder/ExternalEvent/ProductReservationRejected/ProductReservationRejectedExternalEvent.php b/src/Saga/CreateOrder/ExternalEvent/ProductReservationRejected/ProductReservationRejectedExternalEvent.php new file mode 100644 index 0000000..9b6f634 --- /dev/null +++ b/src/Saga/CreateOrder/ExternalEvent/ProductReservationRejected/ProductReservationRejectedExternalEvent.php @@ -0,0 +1,15 @@ + $productIds + */ + public function __construct(public array $productIds, public string $orderId) + { + } +} diff --git a/src/Saga/CreateOrder/ExternalEvent/ProductReservationRejected/ProductReservationRejectedExternalEventHandler.php b/src/Saga/CreateOrder/ExternalEvent/ProductReservationRejected/ProductReservationRejectedExternalEventHandler.php new file mode 100644 index 0000000..54a813f --- /dev/null +++ b/src/Saga/CreateOrder/ExternalEvent/ProductReservationRejected/ProductReservationRejectedExternalEventHandler.php @@ -0,0 +1,20 @@ +createOrderChoreographySagaService->handleProductReservationRejectedEvent($event); + } +} diff --git a/src/Saga/CreateOrder/ExternalEvent/ProductsReserved/ProductsReservedExternalEvent.php b/src/Saga/CreateOrder/ExternalEvent/ProductsReserved/ProductsReservedExternalEvent.php new file mode 100644 index 0000000..239e098 --- /dev/null +++ b/src/Saga/CreateOrder/ExternalEvent/ProductsReserved/ProductsReservedExternalEvent.php @@ -0,0 +1,15 @@ + $productIds + */ + public function __construct(public array $productIds, public string $orderId) + { + } +} diff --git a/src/Saga/CreateOrder/ExternalEvent/ProductsReserved/ProductsReservedExternalEventHandler.php b/src/Saga/CreateOrder/ExternalEvent/ProductsReserved/ProductsReservedExternalEventHandler.php new file mode 100644 index 0000000..818e8ac --- /dev/null +++ b/src/Saga/CreateOrder/ExternalEvent/ProductsReserved/ProductsReservedExternalEventHandler.php @@ -0,0 +1,20 @@ +createOrderChoreographySagaService->handleProductsReservedEvent($event); + } +} diff --git a/src/Saga/CreateOrder/Repository/CreateOrderSagaRepository.php b/src/Saga/CreateOrder/Repository/CreateOrderSagaRepository.php new file mode 100644 index 0000000..f0cdda6 --- /dev/null +++ b/src/Saga/CreateOrder/Repository/CreateOrderSagaRepository.php @@ -0,0 +1,34 @@ +_em->persist($saga); + $this->_em->flush(); + } + + public function findLastState(string $orderId): ?CreateOrderSagaEntity + { + return $this->createQueryBuilder('s') + ->where('s.orderId = :orderId') + ->setParameter('orderId', $orderId) + ->orderBy('s.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/src/Saga/CreateOrder/SagaMarkingStore.php b/src/Saga/CreateOrder/SagaMarkingStore.php new file mode 100644 index 0000000..b538d5f --- /dev/null +++ b/src/Saga/CreateOrder/SagaMarkingStore.php @@ -0,0 +1,27 @@ +getState()->value => 1]); + } + + /** + * @param object|CreateOrderSagaEntity $subject + */ + public function setMarking(object $subject, Marking $marking, array $context = []): void + { + $marking = key($marking->getPlaces()); + $subject->setState(State::from($marking)); + } +} diff --git a/src/Saga/CreateOrder/Service/Choreography/CreateOrderChoreography.php b/src/Saga/CreateOrder/Service/Choreography/CreateOrderChoreography.php new file mode 100644 index 0000000..526f76c --- /dev/null +++ b/src/Saga/CreateOrder/Service/Choreography/CreateOrderChoreography.php @@ -0,0 +1,63 @@ +addState($event->orderId, State::RESERVATION_PENDING, $event); + } + + public function handleProductsReservedEvent(ProductsReservedExternalEvent $event): void + { + $this->addState($event->orderId, State::RESERVATION_CONFIRMED, $event); + $this->addState($event->orderId, State::PAYMENT_PENDING, $event); + } + + public function handleProductReservationRejectedEvent(ProductReservationRejectedExternalEvent $event): void + { + $this->addState($event->orderId, State::RESERVATION_REJECTED, $event); + } + + public function handleInvoicePaidEvent(InvoicePaidExternalEvent $event): void + { + $this->addState($event->orderId, State::PAYMENT_CONFIRMED, $event); + } + + public function handleInvoiceCancelledEvent(InvoiceCancelledExternalEvent $event): void + { + $this->addState($event->orderId, State::PAYMENT_REJECTED, $event); + } + + public function handleOrderCompletedEvent(OrderCompletedExternalEvent $event): void + { + $this->addState($event->orderId, State::ORDER_COMPLETED, $event); + } + + public function handleOrderCancelledEvent(OrderCancelledExternalEvent $event): void + { + $this->addState($event->orderId, State::ORDER_CANCELLED, $event); + } + + public function addState(string $orderId, State $state, object|array $payload = []): void + { + $this->sagaState->addState($orderId, $state, $payload); + } +} diff --git a/src/Saga/CreateOrder/Service/Orchestrator/CreateOrderOrchestrator.php b/src/Saga/CreateOrder/Service/Orchestrator/CreateOrderOrchestrator.php new file mode 100644 index 0000000..9c74098 --- /dev/null +++ b/src/Saga/CreateOrder/Service/Orchestrator/CreateOrderOrchestrator.php @@ -0,0 +1,51 @@ +createOrderStep, + $this->reserveProductsStep, + $this->payOrderStep, + ]; + + /** @var StepInterface[] $compensatingSteps */ + $compensatingSteps = []; + foreach ($steps as $step) { + try { + $step->execute($orderId); + array_unshift($compensatingSteps, $step); + } catch (\Exception $e) { + $this->compensate($orderId, $compensatingSteps); + break; + } + } + } + + /** + * @param StepInterface[] $compensatingSteps + */ + private function compensate(string $orderId, array $compensatingSteps): void + { + foreach ($compensatingSteps as $step) { + $step->compensate($orderId); + } + } +} diff --git a/src/Saga/CreateOrder/Service/Orchestrator/StepInterface.php b/src/Saga/CreateOrder/Service/Orchestrator/StepInterface.php new file mode 100644 index 0000000..23cd57a --- /dev/null +++ b/src/Saga/CreateOrder/Service/Orchestrator/StepInterface.php @@ -0,0 +1,12 @@ +orderService->cancelOrder($orderId); + $this->sagaState->addState($orderId, State::ORDER_CANCELLED); + } +} diff --git a/src/Saga/CreateOrder/Service/Orchestrator/Steps/PayOrderStep.php b/src/Saga/CreateOrder/Service/Orchestrator/Steps/PayOrderStep.php new file mode 100644 index 0000000..f844cdd --- /dev/null +++ b/src/Saga/CreateOrder/Service/Orchestrator/Steps/PayOrderStep.php @@ -0,0 +1,35 @@ +sagaState->addState($orderId, State::PAYMENT_PENDING); + + try { + $this->paymentService->payOrder($orderId); + $this->sagaState->addState($orderId, State::PAYMENT_CONFIRMED); + } catch (\Exception $e) { + $this->sagaState->addState($orderId, State::PAYMENT_REJECTED); + throw $e; + } + } + + public function compensate(string $orderId): void + { + $this->paymentService->refundPayment($orderId); + } +} diff --git a/src/Saga/CreateOrder/Service/Orchestrator/Steps/ReserveProductsStep.php b/src/Saga/CreateOrder/Service/Orchestrator/Steps/ReserveProductsStep.php new file mode 100644 index 0000000..31f1987 --- /dev/null +++ b/src/Saga/CreateOrder/Service/Orchestrator/Steps/ReserveProductsStep.php @@ -0,0 +1,35 @@ +sagaState->addState($orderId, State::RESERVATION_PENDING); + + try { + $this->inventoryService->reserveProducts($orderId); + $this->sagaState->addState($orderId, State::RESERVATION_CONFIRMED); + } catch (\Exception $e) { + $this->sagaState->addState($orderId, State::RESERVATION_REJECTED); + throw $e; + } + } + + public function compensate(string $orderId): void + { + $this->inventoryService->releaseReservedProducts($orderId); + } +} diff --git a/src/Saga/CreateOrder/Service/SagaStateService.php b/src/Saga/CreateOrder/Service/SagaStateService.php new file mode 100644 index 0000000..3678a34 --- /dev/null +++ b/src/Saga/CreateOrder/Service/SagaStateService.php @@ -0,0 +1,31 @@ +repository->save($sagaState); + } + + public function getSaga(string $orderId): ?CreateOrderSagaEntity + { + return $this->repository->findLastState($orderId); + } +} diff --git a/src/Saga/Event/EventEnvelope.php b/src/Saga/Event/EventEnvelope.php new file mode 100644 index 0000000..ac0755d --- /dev/null +++ b/src/Saga/Event/EventEnvelope.php @@ -0,0 +1,46 @@ +eventId = UlidService::generate(); + $this->eventTime = time(); + $this->eventType = $eventType; + $this->eventData = $eventData; + } + + public function getEventId(): string + { + return $this->eventId; + } + + public function getEventType(): string + { + return $this->eventType; + } + + public function getEventTime(): int + { + return $this->eventTime; + } + + public function getEventData(): array + { + return $this->eventData; + } +} diff --git a/src/Saga/Event/EventEnvelopeHandler.php b/src/Saga/Event/EventEnvelopeHandler.php new file mode 100644 index 0000000..4dcde5a --- /dev/null +++ b/src/Saga/Event/EventEnvelopeHandler.php @@ -0,0 +1,46 @@ + OrderCreatedExternalEvent::class, + EventType::ORDERS_ORDER_CANCELLED => OrderCancelledExternalEvent::class, + EventType::ORDERS_ORDER_COMPLETED => OrderCompletedExternalEvent::class, + EventType::PAYMENTS_INVOICE_PAID => InvoicePaidExternalEvent::class, + EventType::PAYMENTS_INVOICE_CANCELLED => InvoiceCancelledExternalEvent::class, + EventType::INVENTORY_PRODUCTS_RESERVED => ProductsReservedExternalEvent::class, + EventType::INVENTORY_PRODUCTS_RESERVATION_REJECTED => ProductReservationRejectedExternalEvent::class, + ]; + + public function __construct(private DenormalizerInterface $denormalizer, private MessageBusInterface $eventBus) + { + } + + public function __invoke(EventEnvelope $eventEnvelope): void + { + $class = self::EVENT_MAP[$eventEnvelope->getEventType()] ?? null; + if (null === $class) { + return; + } + + $domainEvent = $this->denormalizer->denormalize($eventEnvelope->getEventData(), $class); + $this->eventBus->dispatch($domainEvent); + } +} diff --git a/src/Saga/Event/EventEnvelopeSerializer.php b/src/Saga/Event/EventEnvelopeSerializer.php new file mode 100644 index 0000000..eddf3e7 --- /dev/null +++ b/src/Saga/Event/EventEnvelopeSerializer.php @@ -0,0 +1,46 @@ +serializer->deserialize($encodedEnvelope['body'], EventEnvelope::class, 'json'); + } catch (ExceptionInterface $e) { + throw new MessageDecodingFailedException('Could not decode message: '.$e->getMessage(), $e->getCode(), $e); + } + + return new Envelope($message); + } + + public function encode(Envelope $envelope): array + { + $message = $envelope->getMessage(); + + $headers = ['Content-Type' => 'application/json']; + + return [ + 'body' => $this->serializer->serialize($message, 'json'), + 'headers' => $headers, + ]; + } +} diff --git a/src/Shared/Domain/Event/EventType.php b/src/Shared/Domain/Event/EventType.php index c906c4a..60f467c 100644 --- a/src/Shared/Domain/Event/EventType.php +++ b/src/Shared/Domain/Event/EventType.php @@ -11,5 +11,13 @@ interface EventType public const TESTING_TESTING_SESSION_COMPLETED = 'testing.testing_session_completed'; public const SKILLS_SKILL_CONFIRMATION_CREATED = 'skills.skill_confirmation_created'; public const ORDERS_ORDER_CREATED = 'orders.order_created'; + public const INVENTORY_PRODUCTS_RESERVED = 'inventory.product_reserved'; + public const INVENTORY_PRODUCTS_RESERVATION_RELEASED = 'inventory.product_reservation_released'; + public const INVENTORY_PRODUCTS_RESERVATION_REJECTED = 'inventory.product_reservation_rejected'; public const PAYMENTS_INVOICE_PAID = 'payments.invoice_paid'; + public const PAYMENTS_INVOICE_CANCELLED = 'payments.invoice_cancelled'; + public const ORDERS_ORDER_PAID = 'orders.order_paid'; + public const ORDERS_ORDER_COMPLETED = 'orders.order_completed'; + public const ORDERS_ORDER_CANCELLED = 'orders.order_cancelled'; + public const PAYMENTS_PAYMENT_PAID = 'payments.payment_paid'; } diff --git a/src/Shared/Infrastructure/Database/Migrations/Version20240117091038.php b/src/Shared/Infrastructure/Database/Migrations/Version20240205205103.php similarity index 71% rename from src/Shared/Infrastructure/Database/Migrations/Version20240117091038.php rename to src/Shared/Infrastructure/Database/Migrations/Version20240205205103.php index 7b9e413..31bf0e0 100644 --- a/src/Shared/Infrastructure/Database/Migrations/Version20240117091038.php +++ b/src/Shared/Infrastructure/Database/Migrations/Version20240205205103.php @@ -10,7 +10,7 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20240117091038 extends AbstractMigration +final class Version20240205205103 extends AbstractMigration { public function getDescription(): string { @@ -20,19 +20,18 @@ public function getDescription(): string public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE SEQUENCE refresh_tokens_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE refresh_token_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE saga_create_order_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE TABLE orders_customer (id VARCHAR(26) NOT NULL, public_user_id VARCHAR(26) NOT NULL, email VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); - $this->addSql('CREATE TABLE orders_order (id VARCHAR(26) NOT NULL, customer_id VARCHAR(26) NOT NULL, status VARCHAR(255) NOT NULL, payment_method VARCHAR(255) NOT NULL, total_price INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE orders_order (id VARCHAR(26) NOT NULL, customer_id VARCHAR(26) NOT NULL, status VARCHAR(255) NOT NULL, payment_method VARCHAR(255) NOT NULL, total_price INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, state VARCHAR(255) DEFAULT \'created\' NOT NULL, PRIMARY KEY(id))'); $this->addSql('COMMENT ON COLUMN orders_order.created_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('CREATE TABLE orders_order_item (id VARCHAR(26) NOT NULL, order_id VARCHAR(26) DEFAULT NULL, price INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, product_id VARCHAR(26) NOT NULL, product_name VARCHAR(255) NOT NULL, product_type VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_3728068E8D9F6D38 ON orders_order_item (order_id)'); $this->addSql('COMMENT ON COLUMN orders_order_item.created_at IS \'(DC2Type:datetime_immutable)\''); - $this->addSql('CREATE TABLE payments_customer (id VARCHAR(26) NOT NULL, public_user_id VARCHAR(26) NOT NULL, PRIMARY KEY(id))'); - $this->addSql('CREATE TABLE payments_invoice (id VARCHAR(26) NOT NULL, order_id VARCHAR(26) NOT NULL, customer_id VARCHAR(26) NOT NULL, status VARCHAR(255) NOT NULL, amount INT NOT NULL, items json NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); - $this->addSql('CREATE UNIQUE INDEX UNIQ_C02EC5E68D9F6D38 ON payments_invoice (order_id)'); - $this->addSql('COMMENT ON COLUMN payments_invoice.created_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('CREATE TABLE refresh_token (id INT NOT NULL, refresh_token VARCHAR(128) NOT NULL, username VARCHAR(255) NOT NULL, valid TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX UNIQ_C74F2195C74F2195 ON refresh_token (refresh_token)'); + $this->addSql('CREATE TABLE saga_create_order (id VARCHAR(255) NOT NULL, order_id VARCHAR(255) NOT NULL, state VARCHAR(255) NOT NULL, payload TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN saga_create_order.created_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('CREATE TABLE skills_skill (id VARCHAR(26) NOT NULL, skill_group_id VARCHAR(26) DEFAULT NULL, name VARCHAR(255) NOT NULL, description VARCHAR(255) DEFAULT \'\' NOT NULL, owner_id VARCHAR(26) NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE INDEX IDX_F3520F17BCFCB4B5 ON skills_skill (skill_group_id)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_F3520F175E237E06BCFCB4B5 ON skills_skill (name, skill_group_id)'); @@ -78,6 +77,51 @@ public function up(Schema $schema): void $this->addSql('COMMENT ON COLUMN training_material.updated_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('CREATE TABLE users_user (id VARCHAR(26) NOT NULL, email VARCHAR(255) NOT NULL, password VARCHAR(255) DEFAULT NULL, roles JSON DEFAULT \'[]\' NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX UNIQ_421A9847E7927C74 ON users_user (email)'); + $this->addSql('CREATE TABLE orders_outbox_message (id BIGSERIAL NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_E8F01EC0FB7336F0 ON orders_outbox_message (queue_name)'); + $this->addSql('CREATE INDEX IDX_E8F01EC0E3BD61CE ON orders_outbox_message (available_at)'); + $this->addSql('CREATE INDEX IDX_E8F01EC016BA31DB ON orders_outbox_message (delivered_at)'); + $this->addSql('COMMENT ON COLUMN orders_outbox_message.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN orders_outbox_message.available_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN orders_outbox_message.delivered_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE OR REPLACE FUNCTION notify_orders_outbox_message() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify(\'orders_outbox_message\', NEW.queue_name::text); + RETURN NEW; + END; + $$ LANGUAGE plpgsql;'); + $this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON orders_outbox_message;'); + $this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON orders_outbox_message FOR EACH ROW EXECUTE PROCEDURE notify_orders_outbox_message();'); + $this->addSql('CREATE TABLE inventory_outbox_message (id BIGSERIAL NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_F52E1360FB7336F0 ON inventory_outbox_message (queue_name)'); + $this->addSql('CREATE INDEX IDX_F52E1360E3BD61CE ON inventory_outbox_message (available_at)'); + $this->addSql('CREATE INDEX IDX_F52E136016BA31DB ON inventory_outbox_message (delivered_at)'); + $this->addSql('COMMENT ON COLUMN inventory_outbox_message.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN inventory_outbox_message.available_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN inventory_outbox_message.delivered_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE OR REPLACE FUNCTION notify_inventory_outbox_message() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify(\'inventory_outbox_message\', NEW.queue_name::text); + RETURN NEW; + END; + $$ LANGUAGE plpgsql;'); + $this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON inventory_outbox_message;'); + $this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON inventory_outbox_message FOR EACH ROW EXECUTE PROCEDURE notify_inventory_outbox_message();'); + $this->addSql('CREATE TABLE payments_outbox_message (id BIGSERIAL NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_2471313FFB7336F0 ON payments_outbox_message (queue_name)'); + $this->addSql('CREATE INDEX IDX_2471313FE3BD61CE ON payments_outbox_message (available_at)'); + $this->addSql('CREATE INDEX IDX_2471313F16BA31DB ON payments_outbox_message (delivered_at)'); + $this->addSql('COMMENT ON COLUMN payments_outbox_message.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN payments_outbox_message.available_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN payments_outbox_message.delivered_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE OR REPLACE FUNCTION notify_payments_outbox_message() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify(\'payments_outbox_message\', NEW.queue_name::text); + RETURN NEW; + END; + $$ LANGUAGE plpgsql;'); + $this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON payments_outbox_message;'); + $this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON payments_outbox_message FOR EACH ROW EXECUTE PROCEDURE notify_payments_outbox_message();'); $this->addSql('ALTER TABLE orders_order_item ADD CONSTRAINT FK_3728068E8D9F6D38 FOREIGN KEY (order_id) REFERENCES orders_order (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE skills_skill ADD CONSTRAINT FK_F3520F17BCFCB4B5 FOREIGN KEY (skill_group_id) REFERENCES skills_skill_group (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE skills_skill_confirmation ADD CONSTRAINT FK_722F1C197B100C1A FOREIGN KEY (specialist_id) REFERENCES skills_specialist (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); @@ -98,7 +142,8 @@ public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); - $this->addSql('DROP SEQUENCE refresh_tokens_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE refresh_token_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE saga_create_order_id_seq CASCADE'); $this->addSql('ALTER TABLE orders_order_item DROP CONSTRAINT FK_3728068E8D9F6D38'); $this->addSql('ALTER TABLE skills_skill DROP CONSTRAINT FK_F3520F17BCFCB4B5'); $this->addSql('ALTER TABLE skills_skill_confirmation DROP CONSTRAINT FK_722F1C197B100C1A'); @@ -116,9 +161,8 @@ public function down(Schema $schema): void $this->addSql('DROP TABLE orders_customer'); $this->addSql('DROP TABLE orders_order'); $this->addSql('DROP TABLE orders_order_item'); - $this->addSql('DROP TABLE payments_customer'); - $this->addSql('DROP TABLE payments_invoice'); $this->addSql('DROP TABLE refresh_token'); + $this->addSql('DROP TABLE saga_create_order'); $this->addSql('DROP TABLE skills_skill'); $this->addSql('DROP TABLE skills_skill_confirmation'); $this->addSql('DROP TABLE skills_skill_confirmation_proof'); @@ -134,5 +178,8 @@ public function down(Schema $schema): void $this->addSql('DROP TABLE testing_user_answer'); $this->addSql('DROP TABLE training_material'); $this->addSql('DROP TABLE users_user'); + $this->addSql('DROP TABLE orders_outbox_message'); + $this->addSql('DROP TABLE inventory_outbox_message'); + $this->addSql('DROP TABLE payments_outbox_message'); } } diff --git a/src/Shared/Infrastructure/Database/Migrations/Version20240119175426.php b/src/Shared/Infrastructure/Database/Migrations/Version20240205210058.php similarity index 53% rename from src/Shared/Infrastructure/Database/Migrations/Version20240119175426.php rename to src/Shared/Infrastructure/Database/Migrations/Version20240205210058.php index 8251273..50db1a2 100644 --- a/src/Shared/Infrastructure/Database/Migrations/Version20240119175426.php +++ b/src/Shared/Infrastructure/Database/Migrations/Version20240205210058.php @@ -10,7 +10,7 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20240119175426 extends AbstractMigration +final class Version20240205210058 extends AbstractMigration { public function getDescription(): string { @@ -20,17 +20,13 @@ public function getDescription(): string public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs - $this->addSql('DROP SEQUENCE refresh_tokens_id_seq CASCADE'); - $this->addSql('CREATE SEQUENCE refresh_token_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); - $this->addSql('COMMENT ON COLUMN payments_invoice.items IS \'(DC2Type:invoice_items)\''); + $this->addSql('ALTER TABLE orders_order DROP state'); } public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('CREATE SCHEMA public'); - $this->addSql('DROP SEQUENCE refresh_token_id_seq CASCADE'); - $this->addSql('CREATE SEQUENCE refresh_tokens_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); - $this->addSql('COMMENT ON COLUMN payments_invoice.items IS NULL'); + $this->addSql('ALTER TABLE orders_order ADD state VARCHAR(255) DEFAULT \'created\' NOT NULL'); } } diff --git a/src/Shared/Infrastructure/Database/Migrations/Version20240218113228.php b/src/Shared/Infrastructure/Database/Migrations/Version20240218113228.php new file mode 100644 index 0000000..3a859de --- /dev/null +++ b/src/Shared/Infrastructure/Database/Migrations/Version20240218113228.php @@ -0,0 +1,42 @@ +addSql('CREATE TABLE payments_customer (id VARCHAR(26) NOT NULL, public_user_id VARCHAR(26) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE payments_invoice (id VARCHAR(26) NOT NULL, order_id VARCHAR(26) NOT NULL, customer_id VARCHAR(26) NOT NULL, status VARCHAR(255) NOT NULL, payment_method VARCHAR(255) NOT NULL, amount INT NOT NULL, items json NOT NULL NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_C02EC5E68D9F6D38 ON payments_invoice (order_id)'); + $this->addSql('COMMENT ON COLUMN payments_invoice.items IS \'(DC2Type:invoice_items)\''); + $this->addSql('COMMENT ON COLUMN payments_invoice.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE payments_payment (id VARCHAR(26) NOT NULL, invoice_id VARCHAR(26) NOT NULL, customer_id VARCHAR(26) NOT NULL, external_payment_id VARCHAR(255) DEFAULT NULL, status VARCHAR(255) NOT NULL, payment_method VARCHAR(255) NOT NULL, response JSON DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_3D6356AF2989F1FD ON payments_payment (invoice_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_3D6356AF1E3D2C69 ON payments_payment (external_payment_id)'); + $this->addSql('COMMENT ON COLUMN payments_payment.created_at IS \'(DC2Type:datetime_immutable)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP TABLE payments_customer'); + $this->addSql('DROP TABLE payments_invoice'); + $this->addSql('DROP TABLE payments_payment'); + } +} diff --git a/src/Shared/Infrastructure/Database/Migrations/Version20240218194337.php b/src/Shared/Infrastructure/Database/Migrations/Version20240218194337.php new file mode 100644 index 0000000..75e06c5 --- /dev/null +++ b/src/Shared/Infrastructure/Database/Migrations/Version20240218194337.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE inventory_product (id VARCHAR(26) NOT NULL, quantity INT NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE inventory_product_reservation (id VARCHAR(26) NOT NULL, product_id VARCHAR(26) NOT NULL, order_id VARCHAR(26) NOT NULL, quantity INT NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_76AAB3EA4584665A8D9F6D38 ON inventory_product_reservation (product_id, order_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP TABLE inventory_product'); + $this->addSql('DROP TABLE inventory_product_reservation'); + } +} diff --git a/src/Skills/Infrastructure/Event/EventEnvelopeHandler.php b/src/Skills/Infrastructure/Event/EventEnvelopeHandler.php index cfdd730..e8feeff 100644 --- a/src/Skills/Infrastructure/Event/EventEnvelopeHandler.php +++ b/src/Skills/Infrastructure/Event/EventEnvelopeHandler.php @@ -13,7 +13,7 @@ #[AsMessageHandler] final class EventEnvelopeHandler { - private const MAP = [ + private const EVENT_MAP = [ EventType::USERS_USER_CREATED => UserCreatedExternalEvent::class, ]; @@ -23,18 +23,12 @@ public function __construct(private DenormalizerInterface $denormalizer, private public function __invoke(EventEnvelope $eventEnvelope): void { - $domainEvent = $this->denormalizer->denormalize( - $eventEnvelope->getEventData(), - $this->getClassByType($eventEnvelope->getEventType()) - ); - $this->eventBus->dispatch($domainEvent); - } + $class = self::EVENT_MAP[$eventEnvelope->getEventType()] ?? null; + if (null === $class) { + return; + } - /** - * @return class-string|null - */ - private function getClassByType(string $type): ?string - { - return self::MAP[$type] ?? null; + $domainEvent = $this->denormalizer->denormalize($eventEnvelope->getEventData(), $class); + $this->eventBus->dispatch($domainEvent); } } diff --git a/symfony.lock b/symfony.lock index 0a33758..25c5827 100644 --- a/symfony.lock +++ b/symfony.lock @@ -526,6 +526,18 @@ "symfony/var-exporter": { "version": "v6.0.3" }, + "symfony/workflow": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.3", + "ref": "3b2f8ca32a07fcb00f899649053943fa3d8bbfb6" + }, + "files": [ + "config/packages/workflow.yaml" + ] + }, "symfony/yaml": { "version": "v6.0.3" }, diff --git a/tests/Functional/Inventory/Application/UseCase/ReleaseReservedProductsCommandTest.php b/tests/Functional/Inventory/Application/UseCase/ReleaseReservedProductsCommandTest.php new file mode 100644 index 0000000..24cfba8 --- /dev/null +++ b/tests/Functional/Inventory/Application/UseCase/ReleaseReservedProductsCommandTest.php @@ -0,0 +1,50 @@ +commandBus = $this->getService(CommandBusInterface::class); + $this->productReservationRepository = $this->getService(ProductReservationRepositoryInterface::class); + $this->productRepository = $this->getService(ProductRepositoryInterface::class); + } + + public function test_it_should_reserve_products(): void + { + $productReservation = $this->loadProductReservationFixture(); + $product = $this->productRepository->findOne($productReservation->getProductId()); + $productQuantity = $product->getQuantity(); + $command = new ReleaseReservedProductsCommand($productReservation->getOrderId()); + + // act + $this->commandBus->execute($command); + + // assert + $product = $this->productRepository->findOne($product->getId()); + $productReservations = $this->productReservationRepository->findByOrderId($productReservation->getOrderId()); + + $this->assertCount(0, $productReservations); + $this->assertEquals($productQuantity + $productReservation->getQuantity(), $product->getQuantity()); + } +} diff --git a/tests/Functional/Inventory/Application/UseCase/ReserveProductsCommandTest.php b/tests/Functional/Inventory/Application/UseCase/ReserveProductsCommandTest.php new file mode 100644 index 0000000..2b2d44e --- /dev/null +++ b/tests/Functional/Inventory/Application/UseCase/ReserveProductsCommandTest.php @@ -0,0 +1,54 @@ +commandBus = $this->getService(CommandBusInterface::class); + $this->productReservationRepository = $this->getService(ProductReservationRepositoryInterface::class); + $this->productRepository = $this->getService(ProductRepositoryInterface::class); + } + + public function test_it_should_reserve_products(): void + { + $product = $this->loadProductFixture(); + $productQuantity = $product->getQuantity(); + $orderId = UlidService::generate(); + $productIds = [$product->getId()]; + $command = new ReserveProductsCommand($productIds, $orderId); + + // act + $this->commandBus->execute($command); + + // assert + $product = $this->productRepository->findOne($product->getId()); + $productReservations = $this->productReservationRepository->findByOrderId($orderId); + + $this->assertCount(1, $productReservations); + $this->assertEquals($product->getId(), $productReservations[0]->getProductId()); + $this->assertEquals($productReservations[0]->getQuantity(), 1); + $this->assertEquals($productQuantity - 1, $product->getQuantity()); + } +} diff --git a/tests/Functional/Payments/Application/UseCase/Command/CompletePaymentCommandTest.php b/tests/Functional/Payments/Application/UseCase/Command/CompletePaymentCommandTest.php new file mode 100644 index 0000000..d75e5c7 --- /dev/null +++ b/tests/Functional/Payments/Application/UseCase/Command/CompletePaymentCommandTest.php @@ -0,0 +1,56 @@ +paymentRepository = $this->getService(PaymentRepositoryInterface::class); + + $paymentGateway = $this->createMock(PaymentGatewayInterface::class); + $statusResult = new StatusResult( + ['STATUS' => 'success'], + Status::PAID + ); + $paymentGateway->expects($this->once()) + ->method('checkStatus') + ->willReturn($statusResult); + $this->completePaymentCommandHandler = new CompletePaymentCommandHandler( + $this->paymentRepository, + $paymentGateway, + ); + } + + public function test_paid_payment_completed_successfully(): void + { + $payment = $this->loadPaymentFixture(); + + // act + $this->completePaymentCommandHandler->__invoke(new CompletePaymentCommand($payment->getExternalPaymentId())); + + // assert + $payment = $this->paymentRepository->findOneByExternalId($payment->getExternalPaymentId()); + $this->assertTrue($payment->isPaid()); + } +} diff --git a/tests/Functional/Payments/Application/UseCase/Command/CreateInvoiceCommandTest.php b/tests/Functional/Payments/Application/UseCase/Command/CreateInvoiceCommandTest.php new file mode 100644 index 0000000..ba108c3 --- /dev/null +++ b/tests/Functional/Payments/Application/UseCase/Command/CreateInvoiceCommandTest.php @@ -0,0 +1,50 @@ +useCaseInteractor = $this->getService(PaymentsUseCaseInteractor::class); + $this->invoiceRepository = $this->getService(InvoiceRepositoryInterface::class); + } + + public function test_invoice_created_successfully(): void + { + $orderId = UlidService::generate(); + $customerId = UlidService::generate(); + $amount = $this->getFaker()->randomNumber(2); + $items = [ + new Item( + UlidService::generate(), + $this->getFaker()->word(), + $this->getFaker()->randomNumber(5) + ), + ]; + + // act + $this->useCaseInteractor->createInvoice($orderId, $customerId, $amount, $items, PaymentMethod::CARD); + + // assert + $invoice = $this->invoiceRepository->findOneByOrderId($orderId); + $this->assertNotEmpty($invoice); + $this->assertEquals($orderId, $invoice->getOrderId()); + } +} diff --git a/tests/Functional/Payments/Application/UseCase/Command/PayInvoiceCommandTest.php b/tests/Functional/Payments/Application/UseCase/Command/PayInvoiceCommandTest.php new file mode 100644 index 0000000..fa0e5b6 --- /dev/null +++ b/tests/Functional/Payments/Application/UseCase/Command/PayInvoiceCommandTest.php @@ -0,0 +1,62 @@ +invoiceRepository = $this->getService(InvoiceRepositoryInterface::class); + + $paymentGateway = $this->createMock(PaymentGatewayInterface::class); + $paymentResult = new PaymentResult( + 'qwe', + ['STATUS' => 'success'], + true, + $this->getFaker()->url() + ); + $paymentGateway->expects($this->once()) + ->method('pay') + ->willReturn($paymentResult); + $this->payInvoiceCommandHandler = new PayInvoiceCommandHandler( + $this->getService(PaymentService::class), + $this->invoiceRepository, + $paymentGateway, + $this->getService(PaymentRepositoryInterface::class) + ); + } + + /** + * Платеж перешел в статус ожидания подтверждения оплаты. + */ + public function test_payment_has_entered_awaiting_payment_confirmation_status(): void + { + $invoice = $this->loadInvoiceFixture(); + + // act + $paymentResult = $this->payInvoiceCommandHandler->__invoke(new PayInvoiceCommand($invoice->getId(), null)); + + // assert + $this->assertTrue($paymentResult->status->isAwaitingPaymentConfirmation()); + } +} diff --git a/tests/Resource/Fixture/Inventory/ProductFixture.php b/tests/Resource/Fixture/Inventory/ProductFixture.php new file mode 100644 index 0000000..f40dcc7 --- /dev/null +++ b/tests/Resource/Fixture/Inventory/ProductFixture.php @@ -0,0 +1,34 @@ +productFactory->create($productId, 10); + + $manager->persist($product); + $manager->flush(); + + $this->addReference(self::REFERENCE, $product); + } +} diff --git a/tests/Resource/Fixture/Inventory/ProductReservationFixture.php b/tests/Resource/Fixture/Inventory/ProductReservationFixture.php new file mode 100644 index 0000000..3a7242b --- /dev/null +++ b/tests/Resource/Fixture/Inventory/ProductReservationFixture.php @@ -0,0 +1,47 @@ +getReference(ProductFixture::REFERENCE); + $productReservation = $this->productReservationFactory->create($product->getId(), $orderId, 1); + + $manager->persist($productReservation); + $manager->flush(); + + $this->addReference(self::REFERENCE, $productReservation); + } + + public function getDependencies() + { + return [ + ProductFixture::class, + ]; + } +} diff --git a/tests/Resource/Fixture/Payments/InvoiceFixture.php b/tests/Resource/Fixture/Payments/InvoiceFixture.php new file mode 100644 index 0000000..9a88c3a --- /dev/null +++ b/tests/Resource/Fixture/Payments/InvoiceFixture.php @@ -0,0 +1,40 @@ +getFaker()->name(), $this->getFaker()->numberBetween(1, 100)), + ]; + $invoice = $this->invoiceFactory->create($orderId, $customerId, 100, $items, PaymentMethod::CARD); + + $manager->persist($invoice); + $manager->flush(); + + $this->addReference(self::REFERENCE, $invoice); + } +} diff --git a/tests/Resource/Fixture/Payments/PaymentFixture.php b/tests/Resource/Fixture/Payments/PaymentFixture.php new file mode 100644 index 0000000..0f57d80 --- /dev/null +++ b/tests/Resource/Fixture/Payments/PaymentFixture.php @@ -0,0 +1,47 @@ +getReference(InvoiceFixture::REFERENCE); + $payment = $this->paymentFactory->createFromInvoice($invoice); + $payment->setExternalPaymentId($this->getFaker()->uuid()); + $payment->markAwaitingPaymentConfirmation(); + + $manager->persist($payment); + $manager->flush(); + + $this->addReference(self::REFERENCE, $payment); + } + + public function getDependencies() + { + return [ + InvoiceFixture::class, + ]; + } +} diff --git a/tests/Tools/FixtureTools.php b/tests/Tools/FixtureTools.php index 4cd8c7d..1d338bf 100644 --- a/tests/Tools/FixtureTools.php +++ b/tests/Tools/FixtureTools.php @@ -4,7 +4,15 @@ namespace App\Tests\Tools; +use App\Inventory\Domain\Aggregate\Product\Product; +use App\Inventory\Domain\Aggregate\Product\ProductReservation; +use App\Payments\Domain\Aggregate\Invoice\Invoice; +use App\Payments\Domain\Aggregate\Payment\Payment; use App\Skills\Domain\Aggregate\Skill\SkillGroup; +use App\Tests\Resource\Fixture\Inventory\ProductFixture; +use App\Tests\Resource\Fixture\Inventory\ProductReservationFixture; +use App\Tests\Resource\Fixture\Payments\InvoiceFixture; +use App\Tests\Resource\Fixture\Payments\PaymentFixture; use App\Tests\Resource\Fixture\Skills\SkillGroupFixture; use App\Tests\Resource\Fixture\Users\UserFixture; use App\Users\Domain\Aggregate\User\User; @@ -33,4 +41,32 @@ public function loadSkillGroupFixture(): SkillGroup return $executor->getReferenceRepository()->getReference(SkillGroupFixture::REFERENCE); } + + public function loadInvoiceFixture(): Invoice + { + $executor = $this->getDatabaseTools()->loadFixtures([InvoiceFixture::class], true); + + return $executor->getReferenceRepository()->getReference(InvoiceFixture::REFERENCE); + } + + public function loadPaymentFixture(): Payment + { + $executor = $this->getDatabaseTools()->loadFixtures([PaymentFixture::class], true); + + return $executor->getReferenceRepository()->getReference(PaymentFixture::REFERENCE); + } + + private function loadProductFixture(): Product + { + $executor = $this->getDatabaseTools()->loadFixtures([ProductFixture::class], true); + + return $executor->getReferenceRepository()->getReference(ProductFixture::REFERENCE); + } + + public function loadProductReservationFixture(): ProductReservation + { + $executor = $this->getDatabaseTools()->loadFixtures([ProductReservationFixture::class], true); + + return $executor->getReferenceRepository()->getReference(ProductReservationFixture::REFERENCE); + } }