Step 00 — Squelette + intégration naïve

3 Bounded Contexts (Sales, Invoicing, LegacyFulfillment) avec :
- Domaines complets (agrégats, VOs, événements, invariants)
- Couche application (commands, queries, ports)
- Infrastructure in-memory (repos, gateway fake)
- Controllers HTTP Symfony
- Couplage naïf synchrone entre BC via NaiveSalesEventPublisher
- 20 tests unitaires et d'intégration passants
This commit is contained in:
2026-03-04 00:27:15 +01:00
commit a4a14e441b
86 changed files with 7059 additions and 0 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
APP_ENV=dev
APP_DEBUG=1
APP_SECRET=minishop-boilerplate-change-me

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/vendor/
/apps/symfony/var/
/.env.local
/.phpunit.cache/
/.phpunit.result.cache

23
README.md Normal file
View File

@@ -0,0 +1,23 @@
# DDDBoilerplate - MiniShop
Boilerplate DDD minimaliste pour tutoriels Blue Book, pense pour PHP/Symfony et transposable.
Document de reference:
- [docs/MINISHOP_DDD_BLUEBOOK_BOILERPLATE.md](docs/MINISHOP_DDD_BLUEBOOK_BOILERPLATE.md)
Patterns cibles:
- OHS
- ACL
- Conformist
- Published Language
Progression recommandee via tags Git:
- `step/00-naive`
- `step/01-published-language`
- `step/02-conformist`
- `step/03-acl`
- `step/04-ohs`
- `step/05-hardening`

View File

@@ -0,0 +1,11 @@
framework:
secret: '%env(APP_SECRET)%'
test: ~
http_method_override: false
handle_all_throwables: true
php_errors:
log: true
when@test:
framework:
test: true

View File

@@ -0,0 +1,5 @@
controllers:
resource:
path: ../../../src/
namespace: MiniShop
type: attribute

View File

@@ -0,0 +1,62 @@
services:
_defaults:
autowire: true
autoconfigure: true
# --- Shared ---
MiniShop\Shared\:
resource: '%kernel.project_dir%/src/Shared/'
MiniShop\Shared\Technical\Clock:
alias: MiniShop\Shared\Technical\SystemClock
# --- Sales ---
MiniShop\Sales\Application\:
resource: '%kernel.project_dir%/src/Sales/Application/'
MiniShop\Sales\Infrastructure\:
resource: '%kernel.project_dir%/src/Sales/Infrastructure/'
MiniShop\Sales\Interfaces\:
resource: '%kernel.project_dir%/src/Sales/Interfaces/'
tags: ['controller.service_arguments']
MiniShop\Sales\Application\Port\OrderRepository:
alias: MiniShop\Sales\Infrastructure\Persistence\InMemoryOrderRepository
MiniShop\Sales\Application\Port\SalesEventPublisher:
alias: MiniShop\Sales\Infrastructure\Messaging\NaiveSalesEventPublisher
# --- Invoicing ---
MiniShop\Invoicing\Application\:
resource: '%kernel.project_dir%/src/Invoicing/Application/'
MiniShop\Invoicing\Infrastructure\:
resource: '%kernel.project_dir%/src/Invoicing/Infrastructure/'
MiniShop\Invoicing\Interfaces\:
resource: '%kernel.project_dir%/src/Invoicing/Interfaces/'
tags: ['controller.service_arguments']
MiniShop\Invoicing\Application\Port\InvoiceRepository:
alias: MiniShop\Invoicing\Infrastructure\Persistence\InMemoryInvoiceRepository
MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator:
alias: MiniShop\Invoicing\Infrastructure\SequentialInvoiceNumberGenerator
# --- LegacyFulfillment ---
MiniShop\LegacyFulfillment\Application\:
resource: '%kernel.project_dir%/src/LegacyFulfillment/Application/'
MiniShop\LegacyFulfillment\Infrastructure\:
resource: '%kernel.project_dir%/src/LegacyFulfillment/Infrastructure/'
MiniShop\LegacyFulfillment\Interfaces\:
resource: '%kernel.project_dir%/src/LegacyFulfillment/Interfaces/'
tags: ['controller.service_arguments']
MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository:
alias: MiniShop\LegacyFulfillment\Infrastructure\Persistence\InMemoryShipmentRequestRepository
MiniShop\LegacyFulfillment\Application\Port\LegacyFulfillmentGateway:
alias: MiniShop\LegacyFulfillment\Infrastructure\Gateway\FakeLegacyFulfillmentGateway

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\HttpFoundation\Request;
require_once dirname(__DIR__, 3) . '/vendor/autoload.php';
(new Dotenv())->bootEnv(dirname(__DIR__, 3) . '/.env');
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', (bool) ($_SERVER['APP_DEBUG'] ?? true));
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function getConfigDir(): string
{
return $this->getProjectDir() . '/apps/symfony/config';
}
public function getCacheDir(): string
{
return $this->getProjectDir() . '/apps/symfony/var/cache/' . $this->environment;
}
public function getLogDir(): string
{
return $this->getProjectDir() . '/apps/symfony/var/log';
}
}

21
bin/console Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
$input = new ArgvInput();
$env = $input->getParameterOption(['--env', '-e'], $_SERVER['APP_ENV'] ?? 'dev', true);
$debug = (bool) ($_SERVER['APP_DEBUG'] ?? ($env !== 'prod'));
$kernel = new Kernel($env, $debug);
$application = new Application($kernel);
$application->run($input);

34
composer.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "roukmoute/ddd-boilerplate",
"description": "MiniShop - DDD Blue Book Boilerplate",
"type": "project",
"license": "MIT",
"require": {
"php": ">=8.5",
"symfony/framework-bundle": "^7.2",
"symfony/console": "^7.2",
"symfony/dotenv": "^7.2",
"symfony/yaml": "^7.2"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"psr-4": {
"MiniShop\\Sales\\": "src/Sales/",
"MiniShop\\Invoicing\\": "src/Invoicing/",
"MiniShop\\LegacyFulfillment\\": "src/LegacyFulfillment/",
"MiniShop\\Shared\\": "src/Shared/",
"MiniShop\\Contracts\\": "contracts/",
"App\\": "apps/symfony/src/"
}
},
"autoload-dev": {
"psr-4": {
"MiniShop\\Tests\\": "tests/"
}
},
"config": {
"sort-packages": true
}
}

4220
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
# MiniShop DDD Blue Book Boilerplate
Document de reference pour un boilerplate DDD minimaliste, pense pour PHP/Symfony, mais transposable.
## 1. But du boilerplate
Construire une base unique, reutilisable pour des tutoriels progressifs sur les patterns du Blue Book:
- `OHS` (Open Host Service)
- `ACL` (Anti-Corruption Layer)
- `Conformist`
- `Published Language`
Contraintes voulues:
- un seul repo
- `2 a 3 Bounded Contexts` maximum
- interfaces explicites entre contextes
- integration volontairement naive au debut
- evolution pas a pas, concept par concept
## 2. Positionnement et principes
Ce boilerplate privilegie la purete tactique/strategique du DDD, pas la sophistication technique.
Principes directeurs:
1. Un `Bounded Context` = un modele autonome + un langage propre.
2. Aucune entite/VO partagee entre BC par defaut.
3. Les relations entre BC sont explicites via une `Context Map`.
4. Le code inter-BC passe par des contrats (DTO, messages, API), jamais par les modeles internes.
5. Tout pattern (Conformist, ACL, OHS, Published Language) est rendu visible dans l'arborescence et dans les tests.
6. Les choix techniques Symfony/Doctrine restent remplaçables.
## 3. Domaine MiniShop (scope minimal)
### 3.1 Bounded Contexts retenus
- `Sales` (coeur metier): prise de commande
- `Invoicing`: emission de facture
- `LegacyFulfillment`: expedition via systeme legacy volontairement "sale"
`Shipping` peut remplacer `LegacyFulfillment` dans une variante "moderne", mais la version pedagogique recommande `LegacyFulfillment` pour illustrer `ACL`.
### 3.2 Scenario fil rouge
1. Un client passe une commande dans `Sales`.
2. `Sales` notifie les autres BC qu'une commande est confirmee.
3. `Invoicing` produit une facture.
4. `LegacyFulfillment` cree une demande d'expedition dans un format legacy.
## 4. Ubiquitous Language par BC
### 4.1 Sales
- Entites/Aggregats: `Order`
- Value Objects: `OrderId`, `CustomerId`, `Money`, `OrderLine`
- Evenements de domaine: `OrderPlaced`, `OrderConfirmed`
- Invariants:
- une commande a au moins une ligne
- total > 0
- confirmation impossible si commande vide
### 4.2 Invoicing
- Entites/Aggregats: `Invoice`
- Value Objects: `InvoiceId`, `BillingParty`, `InvoiceLine`, `TaxRate`
- Evenements de domaine: `InvoiceIssued`
- Invariants:
- une facture refere une commande externe
- montant facture coherent avec ses lignes
### 4.3 LegacyFulfillment
- Entites/Aggregats: `ShipmentRequest`
- Value Objects: `LegacyOrderRef`, `ShippingAddress`, `ParcelSpec`
- Evenements de domaine: `ShipmentRequested`
- Contraintes:
- format legacy impose (champs abrégés, codifications date/status)
- mapping explicite depuis le langage publie
## 5. Context Map cible (pedagogique)
Etat initial (naif):
- `Sales` appelle directement des services applicatifs externes
- schemas ad hoc, non versionnes
- couplage fort et fragile
Etat cible (apres tutoriels):
- `Sales` expose un `OHS` pour consultation/commande (option REST ou message)
- `Sales` publie un `Published Language` versionne (`sales.v1`)
- `Invoicing` suit `Conformist` sur ce langage publie
- `LegacyFulfillment` introduit un `ACL` entre langage publie et modele legacy
Representation synthese:
```text
Sales (Upstream)
├── Published Language (sales.v1.*)
├── OHS (query/command API)
├──> Invoicing (Conformist)
└──> LegacyFulfillment (ACL)
```
## 6. Architecture de code recommandee
```text
.
├── apps/
│ └── symfony/
│ ├── config/
│ └── public/
├── src/
│ ├── Sales/
│ │ ├── Domain/
│ │ ├── Application/
│ │ ├── Infrastructure/
│ │ └── Interfaces/
│ ├── Invoicing/
│ │ ├── Domain/
│ │ ├── Application/
│ │ ├── Infrastructure/
│ │ └── Interfaces/
│ ├── LegacyFulfillment/
│ │ ├── Domain/
│ │ ├── Application/
│ │ ├── Infrastructure/
│ │ └── Interfaces/
│ └── Shared/
│ └── Technical/ # utilitaires purement techniques (Clock, UUID)
├── contracts/
│ └── sales/
│ └── v1/
│ ├── Event/
│ └── Api/
├── tests/
│ ├── Unit/
│ ├── Integration/
│ └── Contract/
└── docs/
├── MINISHOP_DDD_BLUEBOOK_BOILERPLATE.md
└── tutorials/
```
Regle stricte:
- `src/Shared` ne contient aucun concept metier transverse.
- tout objet dans `contracts/` est stable, versionnable et "publique" entre BC.
## 7. Convention de couches par BC
- `Domain`:
- modeles metier, invariants, services de domaine, evenements de domaine
- aucune dependance framework
- `Application`:
- use cases (commands/queries), orchestration, ports (interfaces)
- `Infrastructure`:
- persistance, bus, clients HTTP, adaptateurs externes
- `Interfaces`:
- HTTP CLI Messenger, serializers, controleurs
## 8. Contrats inter-BC (Published Language)
Le contrat publie minimal recommande:
- `contracts/sales/v1/Event/OrderPlaced.php`
- `contracts/sales/v1/Event/OrderConfirmed.php`
- `contracts/sales/v1/Api/OrderView.php`
Regles:
1. Contrats immutables.
2. Version explicite (`v1`, puis `v2`).
3. Compatibilite retro geree par ajout, pas mutation destructive.
4. Tests de contrat obligatoires (`tests/Contract`).
## 9. Evolution tutorielle (roadmap)
### Episode 00 - Squelette + integration naive
- BC en place
- workflow `PlaceOrder -> Invoice -> LegacyShipment`
- appels synchrones directs inter-BC
- aucun Published Language officiel
Tag Git: `step/00-naive`
### Episode 01 - Published Language
- extraction des DTO publics dans `contracts/sales/v1`
- premiere version de messages d'integration
- tests de schema/serialisation
Tag Git: `step/01-published-language`
### Episode 02 - Conformist (Invoicing)
- `Invoicing` consomme tel quel `sales.v1`
- documentation explicite de la dependance upstream
- tests de non-regression de compatibilite
Tag Git: `step/02-conformist`
### Episode 03 - ACL (LegacyFulfillment)
- creation d'une couche `AntiCorruption` cote `LegacyFulfillment`
- mapping `sales.v1 -> legacy command model`
- isolation des bizarreries legacy dans l'ACL
Tag Git: `step/03-acl`
### Episode 04 - OHS (Sales)
- exposition d'un service hote explicite (`/api/sales/v1/...` ou endpoint message)
- documentation des points d'entree supportes
- modeles internes toujours non exposes
Tag Git: `step/04-ohs`
### Episode 05 - Hardening
- outbox/event relay (optionnel selon niveau tuto)
- idempotence des consommateurs
- monitoring minimal (logs de correlation)
Tag Git: `step/05-hardening`
## 10. Definition of Done par episode
Chaque episode doit fournir:
1. code compilable et testable
2. diagramme context map a jour (`docs/tutorials/`)
3. note "ce qui change" + "pourquoi Blue Book"
4. tag git pour reset rapide
## 11. Strategie de reset pour les tutos
Approche recommandee:
- une branche `main` stable (etat final ou etat de base choisi)
- des tags immuables `step/xx-*`
- une branche de travail recreee a chaque tuto depuis le tag
Exemple:
```bash
git fetch --tags
git switch -C play/episode-03 step/03-acl
```
## 12. Regles de purete Blue Book (checklist)
- Le modele de domaine de chaque BC est independant des autres BC.
- La relation upstream/downstream est documentee.
- Les dependances de code suivent la context map, pas l'inverse.
- Les traductions de langage sont localisees (ACL), jamais diffuses partout.
- Les contrats publies sont versionnes et testes.
- Les cas d'usage ne contournent pas les invariants d'agregat.
## 13. Gabarits minimaux utiles (pseudo-code)
### 13.1 Port de publication d'evenement (Application)
```php
interface SalesEventPublisher
{
public function publishOrderConfirmed(OrderConfirmedMessage $message): void;
}
```
### 13.2 ACL vers legacy (Infrastructure)
```php
final class LegacyShipmentAcl
{
public function fromSalesOrderConfirmed(OrderConfirmedMessage $message): LegacyCreateShipmentCommand
{
// Mapping explicite du langage publie vers le modele legacy.
}
}
```
### 13.3 Use case de Sales (Application)
```php
final class ConfirmOrderHandler
{
public function __invoke(ConfirmOrderCommand $command): void
{
// Charge l'agregat, applique l'invariant, persiste, publie l'evenement d'integration.
}
}
```
## 14. Decoupage recommande des tests
- `tests/Unit/Sales/Domain/*`: invariants d'agregat
- `tests/Unit/Invoicing/Domain/*`: regles de facturation
- `tests/Unit/LegacyFulfillment/Infrastructure/AntiCorruption/*`: mapping ACL
- `tests/Contract/Sales/v1/*`: stabilite du langage publie
- `tests/Integration/*`: wiring Symfony, bus, persistence
## 15. Ce que ce boilerplate n'essaie pas de faire
- couvrir tous les patterns strategiques DDD d'un coup
- fournir une architecture microservices complete
- optimiser prematurement la performance
La priorite est la clarte pedagogique, la reproductibilite et la fidelite Blue Book.
## 16. Specification minimale "codegen-ready"
### 16.1 Sales - cas d'usage
Commandes:
- `PlaceOrder`
- `ConfirmOrder`
- `CancelOrder`
Queries:
- `GetOrderById`
- `ListOrdersByCustomer`
Ports sortants:
- `OrderRepository`
- `SalesEventPublisher`
- `Clock`
- `TransactionManager`
Evenements d'integration publies:
- `sales.v1.OrderPlaced`
- `sales.v1.OrderConfirmed`
- `sales.v1.OrderCancelled`
### 16.2 Invoicing - cas d'usage
Commandes:
- `IssueInvoiceForExternalOrder`
- `MarkInvoiceAsSent`
Queries:
- `GetInvoiceByOrderRef`
Ports sortants:
- `InvoiceRepository`
- `InvoiceNumberGenerator`
Evenements d'integration consommes:
- `sales.v1.OrderConfirmed` (Conformist)
### 16.3 LegacyFulfillment - cas d'usage
Commandes:
- `RequestShipmentFromSalesOrder`
- `MarkShipmentDispatched`
Queries:
- `GetShipmentByExternalOrderRef`
Ports sortants:
- `ShipmentRequestRepository`
- `LegacyFulfillmentGateway`
Evenements d'integration consommes:
- `sales.v1.OrderConfirmed` (via ACL)
## 17. Matrice d'integration inter-BC
| Producteur | Contrat | Consommateur | Pattern | Notes |
|---|---|---|---|---|
| Sales | `sales.v1.OrderPlaced` | (optionnel) Invoicing | Published Language | Peut etre ignore en phase initiale |
| Sales | `sales.v1.OrderConfirmed` | Invoicing | Conformist | Invoicing s'aligne sur le schema upstream |
| Sales | `sales.v1.OrderConfirmed` | LegacyFulfillment | ACL | Traduction vers format legacy |
| Sales | `sales.v1 API` | clients externes | OHS | API stable et versionnee |
## 18. Plan d'implementation concret (ordre recommande)
1. Implementer le domaine `Sales` (aggregate `Order`, invariants, repository in-memory).
2. Ajouter use case `PlaceOrder`, puis `ConfirmOrder`.
3. Brancher `Invoicing` en appel direct naif (episode 00).
4. Introduire `contracts/sales/v1` et migrer les echanges (episode 01).
5. Formaliser `Invoicing` en Conformist avec tests de contrat (episode 02).
6. Introduire `LegacyFulfillment` ACL et isoler tout mapping legacy (episode 03).
7. Exposer `Sales` via OHS versionne (episode 04).
8. Ajouter robustesse (idempotence, outbox optionnelle, correlation-id) (episode 05).
---
Ce document sert de "constitution" du repo MiniShop. Toute evolution de structure doit justifier son impact sur la context map et sur les patterns pedagogiques cibles.

34
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="display_errors" value="1"/>
<ini name="error_reporting" value="-1"/>
<server name="APP_ENV" value="test" force="true"/>
<server name="APP_DEBUG" value="1" force="true"/>
<server name="APP_SECRET" value="test-secret"/>
<server name="KERNEL_CLASS" value="App\Kernel"/>
</php>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="Contract">
<directory>tests/Contract</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
<directory>contracts</directory>
</include>
</source>
</phpunit>

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Command;
final readonly class IssueInvoiceForExternalOrder
{
/**
* @param list<array{description: string, quantity: int, unitPriceInCents: int, currency: string}> $lines
*/
public function __construct(
public string $externalOrderId,
public string $customerName,
public string $customerAddress,
public array $lines,
) {}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Command;
use MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator;
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
use MiniShop\Invoicing\Domain\Model\BillingParty;
use MiniShop\Invoicing\Domain\Model\Invoice;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
use MiniShop\Invoicing\Domain\Model\TaxRate;
use MiniShop\Shared\Technical\Clock;
use MiniShop\Shared\Technical\UuidGenerator;
final readonly class IssueInvoiceForExternalOrderHandler
{
public function __construct(
private InvoiceRepository $invoiceRepository,
private InvoiceNumberGenerator $invoiceNumberGenerator,
private Clock $clock,
) {}
public function __invoke(IssueInvoiceForExternalOrder $command): void
{
$lines = array_map(
static fn (array $line): InvoiceLine => new InvoiceLine(
$line['description'],
$line['quantity'],
$line['unitPriceInCents'],
$line['currency'],
TaxRate::standard(),
),
$command->lines,
);
$invoice = Invoice::issueForExternalOrder(
InvoiceId::fromString(UuidGenerator::generate()),
$this->invoiceNumberGenerator->next(),
$command->externalOrderId,
new BillingParty($command->customerName, $command->customerAddress),
$lines,
$this->clock->now(),
);
$this->invoiceRepository->save($invoice);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Command;
final readonly class MarkInvoiceAsSent
{
public function __construct(
public string $invoiceId,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Command;
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
final readonly class MarkInvoiceAsSentHandler
{
public function __construct(
private InvoiceRepository $invoiceRepository,
) {}
public function __invoke(MarkInvoiceAsSent $command): void
{
$invoice = $this->invoiceRepository->get(InvoiceId::fromString($command->invoiceId));
$invoice->markAsSent();
$this->invoiceRepository->save($invoice);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Port;
interface InvoiceNumberGenerator
{
public function next(): string;
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Port;
use MiniShop\Invoicing\Domain\Model\Invoice;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
interface InvoiceRepository
{
public function save(Invoice $invoice): void;
public function get(InvoiceId $id): Invoice;
public function findByExternalOrderId(string $externalOrderId): ?Invoice;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Query;
final readonly class GetInvoiceByOrderRef
{
public function __construct(
public string $externalOrderId,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Query;
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
use MiniShop\Invoicing\Domain\Model\Invoice;
final readonly class GetInvoiceByOrderRefHandler
{
public function __construct(
private InvoiceRepository $invoiceRepository,
) {}
public function __invoke(GetInvoiceByOrderRef $query): ?Invoice
{
return $this->invoiceRepository->findByExternalOrderId($query->externalOrderId);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Event;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
final readonly class InvoiceIssued
{
public function __construct(
public InvoiceId $invoiceId,
public string $invoiceNumber,
public string $externalOrderId,
public int $totalInclTaxInCents,
public \DateTimeImmutable $issuedAt,
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
final readonly class BillingParty
{
public function __construct(
public string $name,
public string $address,
) {}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
use MiniShop\Invoicing\Domain\Event\InvoiceIssued;
final class Invoice
{
private InvoiceStatus $status;
/** @var list<object> */
private array $domainEvents = [];
/**
* @param list<InvoiceLine> $lines
*/
private function __construct(
public readonly InvoiceId $id,
public readonly string $invoiceNumber,
public readonly string $externalOrderId,
public readonly BillingParty $billingParty,
private readonly array $lines,
private readonly \DateTimeImmutable $issuedAt,
) {
$this->status = InvoiceStatus::Issued;
}
/**
* @param list<InvoiceLine> $lines
*/
public static function issueForExternalOrder(
InvoiceId $id,
string $invoiceNumber,
string $externalOrderId,
BillingParty $billingParty,
array $lines,
\DateTimeImmutable $issuedAt,
): self {
if ($lines === []) {
throw new \DomainException('An invoice must have at least one line.');
}
$invoice = new self($id, $invoiceNumber, $externalOrderId, $billingParty, $lines, $issuedAt);
$invoice->recordEvent(new InvoiceIssued(
$id,
$invoiceNumber,
$externalOrderId,
$invoice->totalInclTax(),
$issuedAt,
));
return $invoice;
}
public function markAsSent(): void
{
if ($this->status === InvoiceStatus::Sent) {
throw new \DomainException('Invoice is already sent.');
}
$this->status = InvoiceStatus::Sent;
}
public function totalExclTax(): int
{
return array_sum(array_map(
static fn (InvoiceLine $line): int => $line->lineTotalExclTax(),
$this->lines,
));
}
public function totalInclTax(): int
{
return array_sum(array_map(
static fn (InvoiceLine $line): int => $line->lineTotalInclTax(),
$this->lines,
));
}
public function status(): InvoiceStatus
{
return $this->status;
}
/** @return list<InvoiceLine> */
public function lines(): array
{
return $this->lines;
}
public function issuedAt(): \DateTimeImmutable
{
return $this->issuedAt;
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
final readonly class InvoiceId
{
private function __construct(public string $value) {}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
final readonly class InvoiceLine
{
public function __construct(
public string $description,
public int $quantity,
public int $unitPriceInCents,
public string $currency,
public TaxRate $taxRate,
) {
if ($quantity <= 0) {
throw new \InvalidArgumentException('Quantity must be positive.');
}
}
public function lineTotalExclTax(): int
{
return $this->quantity * $this->unitPriceInCents;
}
public function lineTotalInclTax(): int
{
return (int) round($this->lineTotalExclTax() * (1 + $this->taxRate->rate));
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
enum InvoiceStatus: string
{
case Issued = 'issued';
case Sent = 'sent';
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
final readonly class TaxRate
{
public function __construct(
public float $rate,
public string $label,
) {
if ($rate < 0 || $rate > 1) {
throw new \InvalidArgumentException('Tax rate must be between 0 and 1.');
}
}
public static function standard(): self
{
return new self(0.20, 'TVA 20%');
}
public static function zero(): self
{
return new self(0.0, 'Exonere');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Infrastructure\Persistence;
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
use MiniShop\Invoicing\Domain\Model\Invoice;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
final class InMemoryInvoiceRepository implements InvoiceRepository
{
/** @var array<string, Invoice> */
private array $invoices = [];
public function save(Invoice $invoice): void
{
$this->invoices[$invoice->id->toString()] = $invoice;
}
public function get(InvoiceId $id): Invoice
{
return $this->invoices[$id->toString()]
?? throw new \RuntimeException(sprintf('Invoice "%s" not found.', $id->toString()));
}
public function findByExternalOrderId(string $externalOrderId): ?Invoice
{
foreach ($this->invoices as $invoice) {
if ($invoice->externalOrderId === $externalOrderId) {
return $invoice;
}
}
return null;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Infrastructure;
use MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator;
final class SequentialInvoiceNumberGenerator implements InvoiceNumberGenerator
{
private int $counter = 0;
public function next(): string
{
return sprintf('INV-%06d', ++$this->counter);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Interfaces\Http;
use MiniShop\Invoicing\Application\Query\GetInvoiceByOrderRef;
use MiniShop\Invoicing\Application\Query\GetInvoiceByOrderRefHandler;
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/invoicing/orders/{externalOrderId}/invoice', name: 'invoicing_get_invoice', methods: ['GET'])]
final readonly class GetInvoiceController
{
public function __construct(
private GetInvoiceByOrderRefHandler $handler,
) {}
public function __invoke(string $externalOrderId): JsonResponse
{
$invoice = ($this->handler)(new GetInvoiceByOrderRef($externalOrderId));
if ($invoice === null) {
return new JsonResponse(['error' => 'Invoice not found.'], Response::HTTP_NOT_FOUND);
}
return new JsonResponse([
'invoiceId' => $invoice->id->toString(),
'invoiceNumber' => $invoice->invoiceNumber,
'externalOrderId' => $invoice->externalOrderId,
'billingParty' => [
'name' => $invoice->billingParty->name,
'address' => $invoice->billingParty->address,
],
'status' => $invoice->status()->value,
'totalExclTax' => $invoice->totalExclTax(),
'totalInclTax' => $invoice->totalInclTax(),
'lines' => array_map(
static fn (InvoiceLine $line): array => [
'description' => $line->description,
'quantity' => $line->quantity,
'unitPriceInCents' => $line->unitPriceInCents,
'lineTotalExclTax' => $line->lineTotalExclTax(),
'lineTotalInclTax' => $line->lineTotalInclTax(),
],
$invoice->lines(),
),
]);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Command;
final readonly class MarkShipmentDispatched
{
public function __construct(
public string $externalOrderId,
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Command;
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
final readonly class MarkShipmentDispatchedHandler
{
public function __construct(
private ShipmentRequestRepository $shipmentRequestRepository,
) {}
public function __invoke(MarkShipmentDispatched $command): void
{
$ref = LegacyOrderRef::fromExternalId($command->externalOrderId);
$request = $this->shipmentRequestRepository->findByOrderRef($ref)
?? throw new \RuntimeException(sprintf('Shipment for order "%s" not found.', $command->externalOrderId));
$request->markDispatched();
$this->shipmentRequestRepository->save($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Command;
final readonly class RequestShipmentFromSalesOrder
{
public function __construct(
public string $externalOrderId,
public string $recipientName,
public string $street,
public string $city,
public string $postalCode,
public string $country,
public int $totalWeightInGrams,
public string $description,
) {}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Command;
use MiniShop\LegacyFulfillment\Application\Port\LegacyFulfillmentGateway;
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ParcelSpec;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
use MiniShop\LegacyFulfillment\Domain\Model\ShippingAddress;
use MiniShop\Shared\Technical\Clock;
final readonly class RequestShipmentFromSalesOrderHandler
{
public function __construct(
private ShipmentRequestRepository $shipmentRequestRepository,
private LegacyFulfillmentGateway $gateway,
private Clock $clock,
) {}
public function __invoke(RequestShipmentFromSalesOrder $command): void
{
$request = ShipmentRequest::fromSalesOrder(
LegacyOrderRef::fromExternalId($command->externalOrderId),
new ShippingAddress(
$command->recipientName,
$command->street,
$command->city,
$command->postalCode,
$command->country,
),
new ParcelSpec($command->totalWeightInGrams, $command->description),
$this->clock->now(),
);
$this->shipmentRequestRepository->save($request);
$this->gateway->sendShipmentRequest($request);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Port;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
interface LegacyFulfillmentGateway
{
public function sendShipmentRequest(ShipmentRequest $request): void;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Port;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
interface ShipmentRequestRepository
{
public function save(ShipmentRequest $request): void;
public function findByOrderRef(LegacyOrderRef $orderRef): ?ShipmentRequest;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Query;
final readonly class GetShipmentByExternalOrderRef
{
public function __construct(
public string $externalOrderId,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Query;
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
final readonly class GetShipmentByExternalOrderRefHandler
{
public function __construct(
private ShipmentRequestRepository $shipmentRequestRepository,
) {}
public function __invoke(GetShipmentByExternalOrderRef $query): ?ShipmentRequest
{
return $this->shipmentRequestRepository->findByOrderRef(
LegacyOrderRef::fromExternalId($query->externalOrderId),
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Event;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
final readonly class ShipmentRequested
{
public function __construct(
public LegacyOrderRef $orderRef,
public \DateTimeImmutable $requestedAt,
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
final readonly class LegacyOrderRef
{
private function __construct(public string $value) {}
public static function fromExternalId(string $externalId): self
{
return new self($externalId);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
final readonly class ParcelSpec
{
public function __construct(
public int $weightInGrams,
public string $description,
) {
if ($weightInGrams <= 0) {
throw new \InvalidArgumentException('Weight must be positive.');
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
use MiniShop\LegacyFulfillment\Domain\Event\ShipmentRequested;
final class ShipmentRequest
{
private ShipmentStatus $status;
/** @var list<object> */
private array $domainEvents = [];
private function __construct(
public readonly LegacyOrderRef $orderRef,
public readonly ShippingAddress $address,
public readonly ParcelSpec $parcel,
private readonly \DateTimeImmutable $requestedAt,
) {
$this->status = ShipmentStatus::Requested;
}
public static function fromSalesOrder(
LegacyOrderRef $orderRef,
ShippingAddress $address,
ParcelSpec $parcel,
\DateTimeImmutable $requestedAt,
): self {
$request = new self($orderRef, $address, $parcel, $requestedAt);
$request->recordEvent(new ShipmentRequested($orderRef, $requestedAt));
return $request;
}
public function markDispatched(): void
{
if ($this->status === ShipmentStatus::Dispatched) {
throw new \DomainException('Shipment already dispatched.');
}
$this->status = ShipmentStatus::Dispatched;
}
public function status(): ShipmentStatus
{
return $this->status;
}
public function requestedAt(): \DateTimeImmutable
{
return $this->requestedAt;
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
enum ShipmentStatus: string
{
case Requested = 'requested';
case Dispatched = 'dispatched';
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
final readonly class ShippingAddress
{
public function __construct(
public string $recipientName,
public string $street,
public string $city,
public string $postalCode,
public string $country,
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Infrastructure\Gateway;
use MiniShop\LegacyFulfillment\Application\Port\LegacyFulfillmentGateway;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
final class FakeLegacyFulfillmentGateway implements LegacyFulfillmentGateway
{
/** @var list<ShipmentRequest> */
private array $sentRequests = [];
public function sendShipmentRequest(ShipmentRequest $request): void
{
$this->sentRequests[] = $request;
}
/** @return list<ShipmentRequest> */
public function sentRequests(): array
{
return $this->sentRequests;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Infrastructure\Persistence;
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
final class InMemoryShipmentRequestRepository implements ShipmentRequestRepository
{
/** @var array<string, ShipmentRequest> */
private array $requests = [];
public function save(ShipmentRequest $request): void
{
$this->requests[$request->orderRef->toString()] = $request;
}
public function findByOrderRef(LegacyOrderRef $orderRef): ?ShipmentRequest
{
return $this->requests[$orderRef->toString()] ?? null;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Interfaces\Http;
use MiniShop\LegacyFulfillment\Application\Query\GetShipmentByExternalOrderRef;
use MiniShop\LegacyFulfillment\Application\Query\GetShipmentByExternalOrderRefHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/fulfillment/orders/{externalOrderId}/shipment', name: 'fulfillment_get_shipment', methods: ['GET'])]
final readonly class GetShipmentController
{
public function __construct(
private GetShipmentByExternalOrderRefHandler $handler,
) {}
public function __invoke(string $externalOrderId): JsonResponse
{
$shipment = ($this->handler)(new GetShipmentByExternalOrderRef($externalOrderId));
if ($shipment === null) {
return new JsonResponse(['error' => 'Shipment not found.'], Response::HTTP_NOT_FOUND);
}
return new JsonResponse([
'orderRef' => $shipment->orderRef->toString(),
'status' => $shipment->status()->value,
'address' => [
'recipientName' => $shipment->address->recipientName,
'street' => $shipment->address->street,
'city' => $shipment->address->city,
'postalCode' => $shipment->address->postalCode,
'country' => $shipment->address->country,
],
'parcel' => [
'weightInGrams' => $shipment->parcel->weightInGrams,
'description' => $shipment->parcel->description,
],
]);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
final readonly class CancelOrder
{
public function __construct(
public string $orderId,
) {}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Application\Port\SalesEventPublisher;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class CancelOrderHandler
{
public function __construct(
private OrderRepository $orderRepository,
private SalesEventPublisher $publisher,
) {}
public function __invoke(CancelOrder $command): void
{
$order = $this->orderRepository->get(OrderId::fromString($command->orderId));
$order->cancel();
$this->orderRepository->save($order);
foreach ($order->releaseEvents() as $event) {
if ($event instanceof OrderCancelled) {
$this->publisher->publishOrderCancelled($event);
}
}
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
final readonly class ConfirmOrder
{
public function __construct(
public string $orderId,
) {}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Application\Port\SalesEventPublisher;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class ConfirmOrderHandler
{
public function __construct(
private OrderRepository $orderRepository,
private SalesEventPublisher $publisher,
) {}
public function __invoke(ConfirmOrder $command): void
{
$order = $this->orderRepository->get(OrderId::fromString($command->orderId));
$order->confirm();
$this->orderRepository->save($order);
foreach ($order->releaseEvents() as $event) {
if ($event instanceof OrderConfirmed) {
$this->publisher->publishOrderConfirmed($event);
}
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
final readonly class PlaceOrder
{
/**
* @param list<array{productName: string, quantity: int, unitPriceInCents: int, currency: string}> $lines
*/
public function __construct(
public string $orderId,
public string $customerId,
public array $lines,
) {}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Application\Port\SalesEventPublisher;
use MiniShop\Sales\Domain\Event\OrderPlaced;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Money;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
use MiniShop\Sales\Domain\Model\OrderLine;
use MiniShop\Shared\Technical\Clock;
final readonly class PlaceOrderHandler
{
public function __construct(
private OrderRepository $orderRepository,
private SalesEventPublisher $publisher,
private Clock $clock,
) {}
public function __invoke(PlaceOrder $command): void
{
$lines = array_map(
static fn (array $line): OrderLine => new OrderLine(
$line['productName'],
$line['quantity'],
new Money($line['unitPriceInCents'], $line['currency']),
),
$command->lines,
);
$order = Order::place(
OrderId::fromString($command->orderId),
CustomerId::fromString($command->customerId),
$lines,
$this->clock->now(),
);
$this->orderRepository->save($order);
foreach ($order->releaseEvents() as $event) {
if ($event instanceof OrderPlaced) {
$this->publisher->publishOrderPlaced($event);
}
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Port;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
interface OrderRepository
{
public function save(Order $order): void;
public function get(OrderId $id): Order;
/** @return list<Order> */
public function findByCustomer(CustomerId $customerId): array;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Port;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Event\OrderPlaced;
interface SalesEventPublisher
{
public function publishOrderPlaced(OrderPlaced $event): void;
public function publishOrderConfirmed(OrderConfirmed $event): void;
public function publishOrderCancelled(OrderCancelled $event): void;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Query;
final readonly class GetOrderById
{
public function __construct(
public string $orderId,
) {}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Query;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class GetOrderByIdHandler
{
public function __construct(
private OrderRepository $orderRepository,
) {}
public function __invoke(GetOrderById $query): Order
{
return $this->orderRepository->get(OrderId::fromString($query->orderId));
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Query;
final readonly class ListOrdersByCustomer
{
public function __construct(
public string $customerId,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Query;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Order;
final readonly class ListOrdersByCustomerHandler
{
public function __construct(
private OrderRepository $orderRepository,
) {}
/** @return list<Order> */
public function __invoke(ListOrdersByCustomer $query): array
{
return $this->orderRepository->findByCustomer(CustomerId::fromString($query->customerId));
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Event;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class OrderCancelled
{
public function __construct(
public OrderId $orderId,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Event;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Money;
use MiniShop\Sales\Domain\Model\OrderId;
use MiniShop\Sales\Domain\Model\OrderLine;
final readonly class OrderConfirmed
{
/**
* @param list<OrderLine> $lines
*/
public function __construct(
public OrderId $orderId,
public CustomerId $customerId,
public Money $total,
public array $lines,
) {}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Event;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Money;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class OrderPlaced
{
public function __construct(
public OrderId $orderId,
public CustomerId $customerId,
public Money $total,
public \DateTimeImmutable $placedAt,
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Exception;
final class EmptyOrderException extends \DomainException
{
public static function noLines(): self
{
return new self('An order must have at least one line.');
}
public static function cannotConfirmEmpty(): self
{
return new self('Cannot confirm an order with no lines.');
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Exception;
use MiniShop\Sales\Domain\Model\OrderStatus;
final class InvalidOrderStateException extends \DomainException
{
public static function cannotConfirm(OrderStatus $current): self
{
return new self(sprintf('Cannot confirm order in "%s" state.', $current->value));
}
public static function cannotCancel(OrderStatus $current): self
{
return new self(sprintf('Cannot cancel order in "%s" state.', $current->value));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
final readonly class CustomerId
{
private function __construct(public string $value) {}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {}
public static function zero(string $currency): self
{
return new self(0, $currency);
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
sprintf('Cannot add %s to %s.', $other->currency, $this->currency),
);
}
return new self($this->amount + $other->amount, $this->currency);
}
public function isPositive(): bool
{
return $this->amount > 0;
}
public function equals(self $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Event\OrderPlaced;
use MiniShop\Sales\Domain\Exception\EmptyOrderException;
use MiniShop\Sales\Domain\Exception\InvalidOrderStateException;
final class Order
{
private OrderStatus $status;
/** @var list<OrderLine> */
private array $lines;
/** @var list<object> */
private array $domainEvents = [];
private function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
private readonly \DateTimeImmutable $placedAt,
) {
$this->status = OrderStatus::Draft;
$this->lines = [];
}
/**
* @param list<OrderLine> $lines
*/
public static function place(
OrderId $id,
CustomerId $customerId,
array $lines,
\DateTimeImmutable $placedAt,
): self {
if ($lines === []) {
throw EmptyOrderException::noLines();
}
$order = new self($id, $customerId, $placedAt);
$order->lines = $lines;
$order->status = OrderStatus::Placed;
$order->recordEvent(new OrderPlaced($id, $customerId, $order->total(), $placedAt));
return $order;
}
public function confirm(): void
{
if ($this->status !== OrderStatus::Placed) {
throw InvalidOrderStateException::cannotConfirm($this->status);
}
$this->status = OrderStatus::Confirmed;
$this->recordEvent(new OrderConfirmed(
$this->id,
$this->customerId,
$this->total(),
$this->lines,
));
}
public function cancel(): void
{
if ($this->status !== OrderStatus::Placed) {
throw InvalidOrderStateException::cannotCancel($this->status);
}
$this->status = OrderStatus::Cancelled;
$this->recordEvent(new OrderCancelled($this->id));
}
public function total(): Money
{
return array_reduce(
$this->lines,
static fn (Money $carry, OrderLine $line): Money => $carry->add($line->lineTotal()),
Money::zero($this->lines[0]->unitPrice->currency),
);
}
public function status(): OrderStatus
{
return $this->status;
}
/** @return list<OrderLine> */
public function lines(): array
{
return $this->lines;
}
public function placedAt(): \DateTimeImmutable
{
return $this->placedAt;
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
final readonly class OrderId
{
private function __construct(public string $value) {}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
final readonly class OrderLine
{
public function __construct(
public string $productName,
public int $quantity,
public Money $unitPrice,
) {
if ($quantity <= 0) {
throw new \InvalidArgumentException('Quantity must be positive.');
}
}
public function lineTotal(): Money
{
return new Money(
$this->unitPrice->amount * $this->quantity,
$this->unitPrice->currency,
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
enum OrderStatus: string
{
case Draft = 'draft';
case Placed = 'placed';
case Confirmed = 'confirmed';
case Cancelled = 'cancelled';
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Infrastructure\Messaging;
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrder;
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler;
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrder;
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrderHandler;
use MiniShop\Sales\Application\Port\SalesEventPublisher;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Event\OrderPlaced;
use MiniShop\Sales\Domain\Model\OrderLine;
/**
* Couplage naif : Sales appelle directement les handlers des autres BC.
* Ce couplage sera supprime dans les episodes suivants (Published Language, Conformist, ACL).
*/
final readonly class NaiveSalesEventPublisher implements SalesEventPublisher
{
public function __construct(
private IssueInvoiceForExternalOrderHandler $issueInvoiceHandler,
private RequestShipmentFromSalesOrderHandler $requestShipmentHandler,
) {}
public function publishOrderPlaced(OrderPlaced $event): void
{
// Pas d'action downstream sur OrderPlaced dans le scenario naif.
}
public function publishOrderConfirmed(OrderConfirmed $event): void
{
// Appel direct vers Invoicing — couplage naif
($this->issueInvoiceHandler)(new IssueInvoiceForExternalOrder(
externalOrderId: $event->orderId->toString(),
customerName: 'Customer ' . $event->customerId->toString(),
customerAddress: 'N/A',
lines: array_map(
static fn (OrderLine $line): array => [
'description' => $line->productName,
'quantity' => $line->quantity,
'unitPriceInCents' => $line->unitPrice->amount,
'currency' => $line->unitPrice->currency,
],
$event->lines,
),
));
// Appel direct vers LegacyFulfillment — couplage naif
($this->requestShipmentHandler)(new RequestShipmentFromSalesOrder(
externalOrderId: $event->orderId->toString(),
recipientName: 'Customer ' . $event->customerId->toString(),
street: '1 Rue du Commerce',
city: 'Paris',
postalCode: '75001',
country: 'FR',
totalWeightInGrams: 1000,
description: sprintf('Order %s', $event->orderId->toString()),
));
}
public function publishOrderCancelled(OrderCancelled $event): void
{
// Pas d'action downstream sur OrderCancelled dans le scenario naif.
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Infrastructure\Persistence;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
final class InMemoryOrderRepository implements OrderRepository
{
/** @var array<string, Order> */
private array $orders = [];
public function save(Order $order): void
{
$this->orders[$order->id->toString()] = $order;
}
public function get(OrderId $id): Order
{
return $this->orders[$id->toString()]
?? throw new \RuntimeException(sprintf('Order "%s" not found.', $id->toString()));
}
/** @return list<Order> */
public function findByCustomer(CustomerId $customerId): array
{
return array_values(
array_filter(
$this->orders,
static fn (Order $order): bool => $order->customerId->equals($customerId),
),
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Command\CancelOrder;
use MiniShop\Sales\Application\Command\CancelOrderHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/orders/{orderId}/cancel', name: 'sales_cancel_order', methods: ['POST'])]
final readonly class CancelOrderController
{
public function __construct(
private CancelOrderHandler $handler,
) {}
public function __invoke(string $orderId): JsonResponse
{
($this->handler)(new CancelOrder(orderId: $orderId));
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Command\ConfirmOrder;
use MiniShop\Sales\Application\Command\ConfirmOrderHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/orders/{orderId}/confirm', name: 'sales_confirm_order', methods: ['POST'])]
final readonly class ConfirmOrderController
{
public function __construct(
private ConfirmOrderHandler $handler,
) {}
public function __invoke(string $orderId): JsonResponse
{
($this->handler)(new ConfirmOrder(orderId: $orderId));
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Query\GetOrderById;
use MiniShop\Sales\Application\Query\GetOrderByIdHandler;
use MiniShop\Sales\Domain\Model\OrderLine;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/orders/{orderId}', name: 'sales_get_order', methods: ['GET'])]
final readonly class GetOrderController
{
public function __construct(
private GetOrderByIdHandler $handler,
) {}
public function __invoke(string $orderId): JsonResponse
{
$order = ($this->handler)(new GetOrderById($orderId));
return new JsonResponse([
'orderId' => $order->id->toString(),
'customerId' => $order->customerId->toString(),
'status' => $order->status()->value,
'total' => [
'amount' => $order->total()->amount,
'currency' => $order->total()->currency,
],
'lines' => array_map(
static fn (OrderLine $line): array => [
'productName' => $line->productName,
'quantity' => $line->quantity,
'unitPrice' => $line->unitPrice->amount,
'currency' => $line->unitPrice->currency,
'lineTotal' => $line->lineTotal()->amount,
],
$order->lines(),
),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Query\ListOrdersByCustomer;
use MiniShop\Sales\Application\Query\ListOrdersByCustomerHandler;
use MiniShop\Sales\Domain\Model\Order;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/customers/{customerId}/orders', name: 'sales_list_customer_orders', methods: ['GET'])]
final readonly class ListCustomerOrdersController
{
public function __construct(
private ListOrdersByCustomerHandler $handler,
) {}
public function __invoke(string $customerId): JsonResponse
{
$orders = ($this->handler)(new ListOrdersByCustomer($customerId));
return new JsonResponse(array_map(
static fn (Order $order): array => [
'orderId' => $order->id->toString(),
'status' => $order->status()->value,
'total' => [
'amount' => $order->total()->amount,
'currency' => $order->total()->currency,
],
],
$orders,
));
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Command\PlaceOrder;
use MiniShop\Sales\Application\Command\PlaceOrderHandler;
use MiniShop\Shared\Technical\UuidGenerator;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/orders', name: 'sales_place_order', methods: ['POST'])]
final readonly class PlaceOrderController
{
public function __construct(
private PlaceOrderHandler $handler,
) {}
public function __invoke(Request $request): JsonResponse
{
/** @var array{customerId: string, lines: list<array{productName: string, quantity: int, unitPriceInCents: int, currency: string}>} $data */
$data = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);
$orderId = UuidGenerator::generate();
($this->handler)(new PlaceOrder(
orderId: $orderId,
customerId: $data['customerId'],
lines: $data['lines'],
));
return new JsonResponse(['orderId' => $orderId], Response::HTTP_CREATED);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace MiniShop\Shared\Technical;
interface Clock
{
public function now(): \DateTimeImmutable;
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MiniShop\Shared\Technical;
final class SystemClock implements Clock
{
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\Shared\Technical;
final class UuidGenerator
{
public static function generate(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
}

0
tests/Contract/.gitkeep Normal file
View File

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace MiniShop\Tests\Integration;
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler;
use MiniShop\Invoicing\Infrastructure\Persistence\InMemoryInvoiceRepository;
use MiniShop\Invoicing\Infrastructure\SequentialInvoiceNumberGenerator;
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrderHandler;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentStatus;
use MiniShop\LegacyFulfillment\Infrastructure\Gateway\FakeLegacyFulfillmentGateway;
use MiniShop\LegacyFulfillment\Infrastructure\Persistence\InMemoryShipmentRequestRepository;
use MiniShop\Sales\Application\Command\ConfirmOrder;
use MiniShop\Sales\Application\Command\ConfirmOrderHandler;
use MiniShop\Sales\Application\Command\PlaceOrder;
use MiniShop\Sales\Application\Command\PlaceOrderHandler;
use MiniShop\Sales\Domain\Model\OrderId;
use MiniShop\Sales\Domain\Model\OrderStatus;
use MiniShop\Sales\Infrastructure\Messaging\NaiveSalesEventPublisher;
use MiniShop\Sales\Infrastructure\Persistence\InMemoryOrderRepository;
use MiniShop\Shared\Technical\SystemClock;
use PHPUnit\Framework\TestCase;
/**
* Test d'integration naif : PlaceOrder -> ConfirmOrder -> Invoice + Shipment.
* Illustre le couplage direct entre BC (episode 00).
*/
final class NaiveWorkflowTest extends TestCase
{
public function test_full_naive_workflow(): void
{
// --- Setup : cablage naif synchrone ---
$clock = new SystemClock();
$orderRepo = new InMemoryOrderRepository();
$invoiceRepo = new InMemoryInvoiceRepository();
$shipmentRepo = new InMemoryShipmentRequestRepository();
$gateway = new FakeLegacyFulfillmentGateway();
$issueInvoiceHandler = new IssueInvoiceForExternalOrderHandler(
$invoiceRepo,
new SequentialInvoiceNumberGenerator(),
$clock,
);
$requestShipmentHandler = new RequestShipmentFromSalesOrderHandler(
$shipmentRepo,
$gateway,
$clock,
);
$publisher = new NaiveSalesEventPublisher($issueInvoiceHandler, $requestShipmentHandler);
$placeOrderHandler = new PlaceOrderHandler($orderRepo, $publisher, $clock);
$confirmOrderHandler = new ConfirmOrderHandler($orderRepo, $publisher);
$orderId = 'test-order-001';
// --- Act : passer et confirmer une commande ---
($placeOrderHandler)(new PlaceOrder(
orderId: $orderId,
customerId: 'cust-001',
lines: [
['productName' => 'Widget', 'quantity' => 2, 'unitPriceInCents' => 1500, 'currency' => 'EUR'],
['productName' => 'Gadget', 'quantity' => 1, 'unitPriceInCents' => 2500, 'currency' => 'EUR'],
],
));
($confirmOrderHandler)(new ConfirmOrder(orderId: $orderId));
// --- Assert : ordre confirme ---
$order = $orderRepo->get(OrderId::fromString($orderId));
self::assertSame(OrderStatus::Confirmed, $order->status());
// --- Assert : facture creee ---
$invoice = $invoiceRepo->findByExternalOrderId($orderId);
self::assertNotNull($invoice, 'An invoice should have been created for the confirmed order.');
self::assertSame($orderId, $invoice->externalOrderId);
self::assertCount(2, $invoice->lines());
// --- Assert : expedition demandee ---
$shipment = $shipmentRepo->findByOrderRef(LegacyOrderRef::fromExternalId($orderId));
self::assertNotNull($shipment, 'A shipment request should have been created.');
self::assertSame(ShipmentStatus::Requested, $shipment->status());
// --- Assert : gateway legacy appele ---
self::assertCount(1, $gateway->sentRequests());
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace MiniShop\Tests\Unit\Invoicing\Domain;
use MiniShop\Invoicing\Domain\Event\InvoiceIssued;
use MiniShop\Invoicing\Domain\Model\BillingParty;
use MiniShop\Invoicing\Domain\Model\Invoice;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
use MiniShop\Invoicing\Domain\Model\InvoiceStatus;
use MiniShop\Invoicing\Domain\Model\TaxRate;
use PHPUnit\Framework\TestCase;
final class InvoiceTest extends TestCase
{
public function test_issue_invoice_for_external_order(): void
{
$invoice = $this->issueInvoice();
self::assertSame(InvoiceStatus::Issued, $invoice->status());
self::assertSame('ext-order-1', $invoice->externalOrderId);
self::assertCount(1, $invoice->lines());
}
public function test_issue_invoice_records_event(): void
{
$invoice = $this->issueInvoice();
$events = $invoice->releaseEvents();
self::assertCount(1, $events);
self::assertInstanceOf(InvoiceIssued::class, $events[0]);
}
public function test_invoice_without_lines_throws(): void
{
$this->expectException(\DomainException::class);
Invoice::issueForExternalOrder(
InvoiceId::fromString('inv-1'),
'INV-000001',
'ext-order-1',
new BillingParty('Acme', '1 Rue Test'),
[],
new \DateTimeImmutable(),
);
}
public function test_total_coherent_with_lines(): void
{
$invoice = Invoice::issueForExternalOrder(
InvoiceId::fromString('inv-1'),
'INV-000001',
'ext-order-1',
new BillingParty('Acme', '1 Rue Test'),
[
new InvoiceLine('Widget', 2, 1000, 'EUR', TaxRate::standard()),
new InvoiceLine('Gadget', 1, 2500, 'EUR', TaxRate::standard()),
],
new \DateTimeImmutable(),
);
self::assertSame(4500, $invoice->totalExclTax());
self::assertSame(5400, $invoice->totalInclTax());
}
public function test_mark_as_sent(): void
{
$invoice = $this->issueInvoice();
$invoice->markAsSent();
self::assertSame(InvoiceStatus::Sent, $invoice->status());
}
public function test_mark_as_sent_twice_throws(): void
{
$this->expectException(\DomainException::class);
$invoice = $this->issueInvoice();
$invoice->markAsSent();
$invoice->markAsSent();
}
private function issueInvoice(): Invoice
{
return Invoice::issueForExternalOrder(
InvoiceId::fromString('inv-1'),
'INV-000001',
'ext-order-1',
new BillingParty('Acme', '1 Rue Test'),
[new InvoiceLine('Widget', 2, 1500, 'EUR', TaxRate::standard())],
new \DateTimeImmutable(),
);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace MiniShop\Tests\Unit\LegacyFulfillment\Domain;
use MiniShop\LegacyFulfillment\Domain\Event\ShipmentRequested;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ParcelSpec;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentStatus;
use MiniShop\LegacyFulfillment\Domain\Model\ShippingAddress;
use PHPUnit\Framework\TestCase;
final class ShipmentRequestTest extends TestCase
{
public function test_create_from_sales_order(): void
{
$request = $this->createRequest();
self::assertSame(ShipmentStatus::Requested, $request->status());
self::assertSame('order-1', $request->orderRef->toString());
}
public function test_create_records_event(): void
{
$request = $this->createRequest();
$events = $request->releaseEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ShipmentRequested::class, $events[0]);
}
public function test_mark_dispatched(): void
{
$request = $this->createRequest();
$request->markDispatched();
self::assertSame(ShipmentStatus::Dispatched, $request->status());
}
public function test_mark_dispatched_twice_throws(): void
{
$this->expectException(\DomainException::class);
$request = $this->createRequest();
$request->markDispatched();
$request->markDispatched();
}
public function test_parcel_with_zero_weight_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
new ParcelSpec(0, 'Widget');
}
private function createRequest(): ShipmentRequest
{
return ShipmentRequest::fromSalesOrder(
LegacyOrderRef::fromExternalId('order-1'),
new ShippingAddress('John Doe', '1 Rue du Commerce', 'Paris', '75001', 'FR'),
new ParcelSpec(1000, 'Order order-1'),
new \DateTimeImmutable(),
);
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace MiniShop\Tests\Unit\Sales\Domain;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Event\OrderPlaced;
use MiniShop\Sales\Domain\Exception\EmptyOrderException;
use MiniShop\Sales\Domain\Exception\InvalidOrderStateException;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Money;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
use MiniShop\Sales\Domain\Model\OrderLine;
use MiniShop\Sales\Domain\Model\OrderStatus;
use PHPUnit\Framework\TestCase;
final class OrderTest extends TestCase
{
public function test_place_order_with_lines(): void
{
$order = $this->placeOrder();
self::assertSame(OrderStatus::Placed, $order->status());
self::assertCount(1, $order->lines());
self::assertTrue($order->total()->isPositive());
}
public function test_place_order_records_order_placed_event(): void
{
$order = $this->placeOrder();
$events = $order->releaseEvents();
self::assertCount(1, $events);
self::assertInstanceOf(OrderPlaced::class, $events[0]);
}
public function test_place_order_without_lines_throws(): void
{
$this->expectException(EmptyOrderException::class);
Order::place(
OrderId::fromString('order-1'),
CustomerId::fromString('cust-1'),
[],
new \DateTimeImmutable(),
);
}
public function test_confirm_placed_order(): void
{
$order = $this->placeOrder();
$order->releaseEvents();
$order->confirm();
self::assertSame(OrderStatus::Confirmed, $order->status());
$events = $order->releaseEvents();
self::assertCount(1, $events);
self::assertInstanceOf(OrderConfirmed::class, $events[0]);
}
public function test_confirm_draft_order_throws(): void
{
$this->expectException(InvalidOrderStateException::class);
$order = $this->placeOrder();
$order->confirm();
$order->releaseEvents();
$order->confirm(); // already confirmed
}
public function test_cancel_placed_order(): void
{
$order = $this->placeOrder();
$order->releaseEvents();
$order->cancel();
self::assertSame(OrderStatus::Cancelled, $order->status());
$events = $order->releaseEvents();
self::assertCount(1, $events);
self::assertInstanceOf(OrderCancelled::class, $events[0]);
}
public function test_cannot_cancel_confirmed_order(): void
{
$this->expectException(InvalidOrderStateException::class);
$order = $this->placeOrder();
$order->confirm();
$order->cancel();
}
public function test_total_is_sum_of_line_totals(): void
{
$order = Order::place(
OrderId::fromString('order-1'),
CustomerId::fromString('cust-1'),
[
new OrderLine('Widget', 2, new Money(1000, 'EUR')),
new OrderLine('Gadget', 1, new Money(2500, 'EUR')),
],
new \DateTimeImmutable(),
);
self::assertTrue($order->total()->equals(new Money(4500, 'EUR')));
}
private function placeOrder(): Order
{
return Order::place(
OrderId::fromString('order-1'),
CustomerId::fromString('cust-1'),
[new OrderLine('Widget', 2, new Money(1500, 'EUR'))],
new \DateTimeImmutable(),
);
}
}