diff --git a/apps/symfony/config/services.yaml b/apps/symfony/config/services.yaml index f8e1b14..bcc6835 100644 --- a/apps/symfony/config/services.yaml +++ b/apps/symfony/config/services.yaml @@ -10,6 +10,9 @@ services: MiniShop\Shared\Technical\Clock: alias: MiniShop\Shared\Technical\SystemClock + MiniShop\Shared\Technical\IdempotencyStore: + alias: MiniShop\Shared\Technical\InMemoryIdempotencyStore + # --- Sales --- MiniShop\Sales\Application\: resource: '%kernel.project_dir%/src/Sales/Application/' diff --git a/contracts/sales/v1/Event/OrderCancelled.php b/contracts/sales/v1/Event/OrderCancelled.php index 34bbb8d..748ca52 100644 --- a/contracts/sales/v1/Event/OrderCancelled.php +++ b/contracts/sales/v1/Event/OrderCancelled.php @@ -12,5 +12,6 @@ final readonly class OrderCancelled { public function __construct( public string $orderId, + public string $correlationId = '', ) {} } diff --git a/contracts/sales/v1/Event/OrderConfirmed.php b/contracts/sales/v1/Event/OrderConfirmed.php index b2fb2f9..a647273 100644 --- a/contracts/sales/v1/Event/OrderConfirmed.php +++ b/contracts/sales/v1/Event/OrderConfirmed.php @@ -19,5 +19,6 @@ final readonly class OrderConfirmed public int $totalInCents, public string $currency, public array $lines, + public string $correlationId = '', ) {} } diff --git a/contracts/sales/v1/Event/OrderPlaced.php b/contracts/sales/v1/Event/OrderPlaced.php index bb706d2..ff6ef75 100644 --- a/contracts/sales/v1/Event/OrderPlaced.php +++ b/contracts/sales/v1/Event/OrderPlaced.php @@ -20,5 +20,6 @@ final readonly class OrderPlaced public string $currency, public string $placedAt, public array $lines, + public string $correlationId = '', ) {} } diff --git a/src/Invoicing/Interfaces/Messaging/WhenOrderConfirmed.php b/src/Invoicing/Interfaces/Messaging/WhenOrderConfirmed.php index 6fd1ab5..b157ac3 100644 --- a/src/Invoicing/Interfaces/Messaging/WhenOrderConfirmed.php +++ b/src/Invoicing/Interfaces/Messaging/WhenOrderConfirmed.php @@ -7,21 +7,42 @@ namespace MiniShop\Invoicing\Interfaces\Messaging; use MiniShop\Contracts\Sales\V1\Event\OrderConfirmed; use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrder; use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler; +use MiniShop\Shared\Technical\IdempotencyStore; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Messenger\Attribute\AsMessageHandler; /** - * Conformist : Invoicing consomme le contrat sales.v1.OrderConfirmed tel quel, - * sans traduction. La dependance upstream est explicite. + * Conformist + Idempotent : consomme sales.v1.OrderConfirmed tel quel. + * Garde d'idempotence pour eviter les doublons en cas de re-delivery. */ #[AsMessageHandler] final readonly class WhenOrderConfirmed { public function __construct( private IssueInvoiceForExternalOrderHandler $handler, + private IdempotencyStore $idempotencyStore, + private LoggerInterface $logger = new NullLogger(), ) {} public function __invoke(OrderConfirmed $message): void { + $idempotencyKey = 'invoicing:order-confirmed:' . $message->orderId; + + if ($this->idempotencyStore->isDuplicate($idempotencyKey)) { + $this->logger->info('Duplicate OrderConfirmed ignored.', [ + 'orderId' => $message->orderId, + 'correlationId' => $message->correlationId, + ]); + + return; + } + + $this->logger->info('Processing OrderConfirmed.', [ + 'orderId' => $message->orderId, + 'correlationId' => $message->correlationId, + ]); + ($this->handler)(new IssueInvoiceForExternalOrder( externalOrderId: $message->orderId, customerName: 'Customer ' . $message->customerId, diff --git a/src/LegacyFulfillment/Interfaces/Messaging/WhenOrderConfirmed.php b/src/LegacyFulfillment/Interfaces/Messaging/WhenOrderConfirmed.php index 5ecaf0a..479e9ca 100644 --- a/src/LegacyFulfillment/Interfaces/Messaging/WhenOrderConfirmed.php +++ b/src/LegacyFulfillment/Interfaces/Messaging/WhenOrderConfirmed.php @@ -8,11 +8,14 @@ use MiniShop\Contracts\Sales\V1\Event\OrderConfirmed; use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrder; use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrderHandler; use MiniShop\LegacyFulfillment\Infrastructure\AntiCorruption\LegacyShipmentAcl; +use MiniShop\Shared\Technical\IdempotencyStore; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Messenger\Attribute\AsMessageHandler; /** - * Consumer ACL : recoit sales.v1.OrderConfirmed, passe par l'Anti-Corruption Layer - * pour traduire vers le modele legacy, puis delegue au handler applicatif. + * Consumer ACL + Idempotent : passe par l'Anti-Corruption Layer avec + * garde d'idempotence pour eviter les doublons. */ #[AsMessageHandler] final readonly class WhenOrderConfirmed @@ -20,10 +23,28 @@ final readonly class WhenOrderConfirmed public function __construct( private LegacyShipmentAcl $acl, private RequestShipmentFromSalesOrderHandler $handler, + private IdempotencyStore $idempotencyStore, + private LoggerInterface $logger = new NullLogger(), ) {} public function __invoke(OrderConfirmed $message): void { + $idempotencyKey = 'fulfillment:order-confirmed:' . $message->orderId; + + if ($this->idempotencyStore->isDuplicate($idempotencyKey)) { + $this->logger->info('Duplicate OrderConfirmed ignored.', [ + 'orderId' => $message->orderId, + 'correlationId' => $message->correlationId, + ]); + + return; + } + + $this->logger->info('Processing OrderConfirmed via ACL.', [ + 'orderId' => $message->orderId, + 'correlationId' => $message->correlationId, + ]); + $legacyCommand = $this->acl->fromSalesOrderConfirmed($message); ($this->handler)(new RequestShipmentFromSalesOrder( diff --git a/src/Sales/Infrastructure/Messaging/MessengerSalesEventPublisher.php b/src/Sales/Infrastructure/Messaging/MessengerSalesEventPublisher.php index df38de2..ebbb135 100644 --- a/src/Sales/Infrastructure/Messaging/MessengerSalesEventPublisher.php +++ b/src/Sales/Infrastructure/Messaging/MessengerSalesEventPublisher.php @@ -12,11 +12,12 @@ use MiniShop\Sales\Domain\Event\OrderCancelled; use MiniShop\Sales\Domain\Event\OrderConfirmed; use MiniShop\Sales\Domain\Event\OrderPlaced; use MiniShop\Sales\Domain\Model\OrderLine; +use MiniShop\Shared\Technical\CorrelationId; use Symfony\Component\Messenger\MessageBusInterface; /** * Publie les evenements de domaine Sales sous forme de contrats Published Language - * via Symfony Messenger. Remplace le NaiveSalesEventPublisher. + * via Symfony Messenger. Propage un correlationId pour le tracing. */ final readonly class MessengerSalesEventPublisher implements SalesEventPublisher { @@ -33,6 +34,7 @@ final readonly class MessengerSalesEventPublisher implements SalesEventPublisher currency: $event->total->currency, placedAt: $event->placedAt->format(\DateTimeInterface::ATOM), lines: [], + correlationId: CorrelationId::generate()->toString(), )); } @@ -52,6 +54,7 @@ final readonly class MessengerSalesEventPublisher implements SalesEventPublisher ], $event->lines, ), + correlationId: CorrelationId::generate()->toString(), )); } @@ -59,6 +62,7 @@ final readonly class MessengerSalesEventPublisher implements SalesEventPublisher { $this->messageBus->dispatch(new OrderCancelledContract( orderId: $event->orderId->toString(), + correlationId: CorrelationId::generate()->toString(), )); } } diff --git a/src/Shared/Technical/CorrelationId.php b/src/Shared/Technical/CorrelationId.php new file mode 100644 index 0000000..a13efb2 --- /dev/null +++ b/src/Shared/Technical/CorrelationId.php @@ -0,0 +1,25 @@ +value; + } +} diff --git a/src/Shared/Technical/IdempotencyStore.php b/src/Shared/Technical/IdempotencyStore.php new file mode 100644 index 0000000..2943f35 --- /dev/null +++ b/src/Shared/Technical/IdempotencyStore.php @@ -0,0 +1,14 @@ + */ + private array $processed = []; + + public function isDuplicate(string $key): bool + { + if (isset($this->processed[$key])) { + return true; + } + + $this->processed[$key] = true; + + return false; + } +} diff --git a/tests/Contract/Sales/v1/ConformistCompatibilityTest.php b/tests/Contract/Sales/v1/ConformistCompatibilityTest.php index 79306bc..7dc3200 100644 --- a/tests/Contract/Sales/v1/ConformistCompatibilityTest.php +++ b/tests/Contract/Sales/v1/ConformistCompatibilityTest.php @@ -10,6 +10,7 @@ use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler; use MiniShop\Invoicing\Infrastructure\Persistence\InMemoryInvoiceRepository; use MiniShop\Invoicing\Infrastructure\SequentialInvoiceNumberGenerator; use MiniShop\Invoicing\Interfaces\Messaging\WhenOrderConfirmed; +use MiniShop\Shared\Technical\InMemoryIdempotencyStore; use MiniShop\Shared\Technical\SystemClock; use PHPUnit\Framework\TestCase; @@ -28,7 +29,7 @@ final class ConformistCompatibilityTest extends TestCase new SequentialInvoiceNumberGenerator(), new SystemClock(), ); - $consumer = new WhenOrderConfirmed($handler); + $consumer = new WhenOrderConfirmed($handler, new InMemoryIdempotencyStore()); $message = new OrderConfirmed( orderId: 'order-conformist-001', diff --git a/tests/Integration/IdempotentConsumerTest.php b/tests/Integration/IdempotentConsumerTest.php new file mode 100644 index 0000000..e8cbda1 --- /dev/null +++ b/tests/Integration/IdempotentConsumerTest.php @@ -0,0 +1,97 @@ +createMessage('idem-001'); + + $consumer($message); + $consumer($message); // duplicate + + // Only one invoice should exist + $invoice = $invoiceRepo->findByExternalOrderId('idem-001'); + self::assertNotNull($invoice); + self::assertSame('INV-000001', $invoice->invoiceNumber); + } + + public function test_fulfillment_consumer_ignores_duplicate(): void + { + $shipmentRepo = new InMemoryShipmentRequestRepository(); + $gateway = new FakeLegacyFulfillmentGateway(); + $idempotencyStore = new InMemoryIdempotencyStore(); + + $consumer = new FulfillmentConsumer( + new LegacyShipmentAcl(), + new RequestShipmentFromSalesOrderHandler($shipmentRepo, $gateway, new SystemClock()), + $idempotencyStore, + ); + + $message = $this->createMessage('idem-002'); + + $consumer($message); + $consumer($message); // duplicate + + // Only one shipment should exist, only one gateway call + self::assertCount(1, $gateway->sentRequests()); + } + + public function test_correlation_id_is_propagated(): void + { + $message = new OrderConfirmed( + orderId: 'corr-001', + customerId: 'cust-001', + totalInCents: 1000, + currency: 'EUR', + lines: [['productName' => 'X', 'quantity' => 1, 'unitPriceInCents' => 1000, 'currency' => 'EUR']], + correlationId: 'corr-id-abc-123', + ); + + self::assertSame('corr-id-abc-123', $message->correlationId); + } + + private function createMessage(string $orderId): OrderConfirmed + { + return new OrderConfirmed( + orderId: $orderId, + customerId: 'cust-001', + totalInCents: 1500, + currency: 'EUR', + lines: [['productName' => 'Widget', 'quantity' => 1, 'unitPriceInCents' => 1500, 'currency' => 'EUR']], + correlationId: 'test-correlation-id', + ); + } +} diff --git a/tests/Integration/InvoicingConformistTest.php b/tests/Integration/InvoicingConformistTest.php index e0a6dc3..724ffaf 100644 --- a/tests/Integration/InvoicingConformistTest.php +++ b/tests/Integration/InvoicingConformistTest.php @@ -9,6 +9,7 @@ use MiniShop\Invoicing\Infrastructure\Persistence\InMemoryInvoiceRepository; use MiniShop\Invoicing\Infrastructure\SequentialInvoiceNumberGenerator; use MiniShop\Invoicing\Interfaces\Messaging\WhenOrderConfirmed; use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler; +use MiniShop\Shared\Technical\InMemoryIdempotencyStore; use MiniShop\Shared\Technical\SystemClock; use PHPUnit\Framework\TestCase; @@ -27,6 +28,7 @@ final class InvoicingConformistTest extends TestCase new SequentialInvoiceNumberGenerator(), new SystemClock(), ), + new InMemoryIdempotencyStore(), ); $consumer(new OrderConfirmed( diff --git a/tests/Integration/LegacyFulfillmentAclTest.php b/tests/Integration/LegacyFulfillmentAclTest.php index 015892f..7c2350a 100644 --- a/tests/Integration/LegacyFulfillmentAclTest.php +++ b/tests/Integration/LegacyFulfillmentAclTest.php @@ -11,6 +11,7 @@ use MiniShop\LegacyFulfillment\Infrastructure\AntiCorruption\LegacyShipmentAcl; use MiniShop\LegacyFulfillment\Infrastructure\Gateway\FakeLegacyFulfillmentGateway; use MiniShop\LegacyFulfillment\Infrastructure\Persistence\InMemoryShipmentRequestRepository; use MiniShop\LegacyFulfillment\Interfaces\Messaging\WhenOrderConfirmed; +use MiniShop\Shared\Technical\InMemoryIdempotencyStore; use MiniShop\Shared\Technical\SystemClock; use PHPUnit\Framework\TestCase; @@ -28,6 +29,7 @@ final class LegacyFulfillmentAclTest extends TestCase $consumer = new WhenOrderConfirmed( new LegacyShipmentAcl(), new RequestShipmentFromSalesOrderHandler($shipmentRepo, $gateway, new SystemClock()), + new InMemoryIdempotencyStore(), ); $consumer(new OrderConfirmed( diff --git a/tests/Unit/Shared/IdempotencyTest.php b/tests/Unit/Shared/IdempotencyTest.php new file mode 100644 index 0000000..0759259 --- /dev/null +++ b/tests/Unit/Shared/IdempotencyTest.php @@ -0,0 +1,36 @@ +isDuplicate('key-1')); + } + + public function test_second_call_is_duplicate(): void + { + $store = new InMemoryIdempotencyStore(); + + $store->isDuplicate('key-1'); + + self::assertTrue($store->isDuplicate('key-1')); + } + + public function test_different_keys_are_independent(): void + { + $store = new InMemoryIdempotencyStore(); + + $store->isDuplicate('key-1'); + + self::assertFalse($store->isDuplicate('key-2')); + } +}